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:
@@ -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