Dingtian web password: set the admin's chosen password, verified

Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
This commit is contained in:
2026-06-15 12:26:34 +02:00
parent 7db5cfa0e4
commit f5fd61984a
6 changed files with 140 additions and 38 deletions
+31 -5
View File
@@ -108,6 +108,9 @@ function CategorySection({
// Show the add-form automatically when nothing is assigned yet; otherwise it's
// collapsed behind "Add another" so the list stays the focus.
const [adding, setAdding] = useState(false);
// Warnings from the most recent save (e.g. "string protocol could not be
// disabled — finish in the device web UI"). Persist after the form closes.
const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
return (
@@ -116,6 +119,28 @@ function CategorySection({
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
</legend>
{warnings.length > 0 && (
<div
style={{
margin: "0 0 0.75rem",
padding: "0.5rem 0.75rem",
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
}}
>
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
Dismiss
</button>
</div>
)}
{assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => (
@@ -130,7 +155,8 @@ function CategorySection({
category={category}
entries={entries}
discoverableIds={discoverableIds}
onSaved={async () => {
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setAdding(false);
}}
@@ -208,7 +234,7 @@ function DeviceForm({
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
onSaved: () => Promise<void> | void;
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
@@ -318,15 +344,15 @@ function DeviceForm({
setSaving(true);
setSaveError(null);
try {
await assignDevice({
const result = await assignDevice({
lane,
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
// Parent reloads the list; this form is unmounted or reset by it.
await onSaved();
// Hand warnings to the parent so they persist after this form unmounts.
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
} finally {
+8 -2
View File
@@ -160,11 +160,11 @@ export interface AssignBody {
}
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<Assignment> {
export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; secrets stripped). */
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
lane: number;
@@ -175,6 +175,12 @@ export interface Assignment {
createdAt?: string;
}
/** Assign response = the saved assignment plus any residual-risk warnings
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
export interface AssignResult extends Assignment {
warnings?: string[];
}
export interface SetupState {
completedAt: string | null;
assignments: Assignment[];