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})
|
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.
|
// findAccount returns the account with accID under taskID, or ok=false.
|
||||||
func (s *Server) findAccount(r *http.Request, taskID, accID int64) (store.Account, bool) {
|
func (s *Server) findAccount(r *http.Request, taskID, accID int64) (store.Account, bool) {
|
||||||
accs, err := s.store.ListAccountsByTask(r.Context(), taskID)
|
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}/runs", s.handleListRuns)
|
||||||
api.HandleFunc("GET /api/tasks/{id}/accounts/{accountId}/errors", s.handleListAccountErrors)
|
api.HandleFunc("GET /api/tasks/{id}/accounts/{accountId}/errors", s.handleListAccountErrors)
|
||||||
api.HandleFunc("DELETE /api/tasks/{id}/accounts/{accountId}", s.handleDeleteAccount)
|
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}/import", s.handleImportCSV)
|
||||||
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
|
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
|
||||||
api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun)
|
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
|
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).
|
// DeleteAccount removes one account (and its migrated_messages via ON DELETE CASCADE).
|
||||||
func (s *Store) DeleteAccount(ctx context.Context, id int64) error {
|
func (s *Store) DeleteAccount(ctx context.Context, id int64) error {
|
||||||
_, err := s.Pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, id)
|
_, 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) {
|
func TestSetAccountFolderMapping(t *testing.T) {
|
||||||
s := testStore(t)
|
s := testStore(t)
|
||||||
ctx := context.Background()
|
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) =>
|
export const deleteAccount = (taskId: number, accountId: number) =>
|
||||||
api(`/api/tasks/${taskId}/accounts/${accountId}`, { method: 'DELETE' })
|
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) =>
|
export const cancelAccount = (taskId: number, accountId: number) =>
|
||||||
api(`/api/tasks/${taskId}/accounts/${accountId}/cancel`, { method: 'POST' })
|
api(`/api/tasks/${taskId}/accounts/${accountId}/cancel`, { method: 'POST' })
|
||||||
|
|
||||||
|
|||||||
@@ -667,6 +667,25 @@ table.tbl a.rowlink:focus-visible {
|
|||||||
|
|
||||||
/* ---------- status badges ---------- */
|
/* ---------- 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 {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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 { 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 { connectTaskWS, type TaskEvent } from '../ws'
|
||||||
import { StatusBadge } from '../components/StatusBadge'
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
import { useConfirm } from '../components/ConfirmProvider'
|
import { useConfirm } from '../components/ConfirmProvider'
|
||||||
@@ -7,6 +7,7 @@ import { FolderMappingModal } from '../components/FolderMappingModal'
|
|||||||
import { RunLogModal } from '../components/RunLogModal'
|
import { RunLogModal } from '../components/RunLogModal'
|
||||||
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
import { AccountErrorsModal } from '../components/AccountErrorsModal'
|
||||||
import { KerioImportModal } from '../components/KerioImportModal'
|
import { KerioImportModal } from '../components/KerioImportModal'
|
||||||
|
import { AccountCredentialsModal } from '../components/AccountCredentialsModal'
|
||||||
|
|
||||||
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
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 [showRuns, setShowRuns] = useState(false)
|
||||||
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
const [errorsFor, setErrorsFor] = useState<{ id: number; src_login: string } | null>(null)
|
||||||
const [kerioOpen, setKerioOpen] = useState(false)
|
const [kerioOpen, setKerioOpen] = useState(false)
|
||||||
|
const [credsFor, setCredsFor] = useState<Account | null>(null)
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
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) {
|
async function onDeleteAccount(accId: number, login: string) {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Remove account',
|
title: 'Remove account',
|
||||||
@@ -405,6 +422,22 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
|
|
||||||
const { task, accounts } = data
|
const { task, accounts } = data
|
||||||
const isRunning = task.status === 'running'
|
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.
|
// A row is selectable only when both connection tests pass and no run is live.
|
||||||
const selectableIds = accounts
|
const selectableIds = accounts
|
||||||
.filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
.filter((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
||||||
@@ -677,12 +710,8 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>{testCell(a, a.test_src_status)}</td>
|
||||||
<StatusBadge status={a.test_src_status} />
|
<td>{testCell(a, a.test_dst_status)}</td>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<StatusBadge status={a.test_dst_status} />
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<StatusBadge status={a.status} />
|
<StatusBadge status={a.status} />
|
||||||
</td>
|
</td>
|
||||||
@@ -820,6 +849,13 @@ export function TaskDetail({ id }: { id: number }) {
|
|||||||
onClose={() => setKerioOpen(false)}
|
onClose={() => setKerioOpen(false)}
|
||||||
onSubmit={onKerioImport}
|
onSubmit={onKerioImport}
|
||||||
/>
|
/>
|
||||||
|
<AccountCredentialsModal
|
||||||
|
open={credsFor !== null}
|
||||||
|
busy={busy === 'add'}
|
||||||
|
account={credsFor}
|
||||||
|
onClose={() => setCredsFor(null)}
|
||||||
|
onSubmit={saveCredentials}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user