Skip to content

Commit 8b8b88c

Browse files
DavidsonGomesclaude
andcommitted
fix(plugins): tolerate whitespace/NBSP in safe_uninstall confirmation phrase
Davidson typed the exact required phrase ("EXCLUIR PLUGIN NUTRI") in the uninstall wizard and got back 400 confirmation_phrase_mismatch. Strict == comparison was punishing the user for an invisible character (likely an NBSP from copy-paste or a trailing whitespace from autofill — the kind of thing operators can't see and shouldn't have to debug). Backend (routes/plugins.py uninstall handler) - Normalize both sides with unicodedata.normalize("NFC") + strip + replace NBSP with regular space before comparing. The phrase is a human-typed confirmation token, not a cryptographic key — tolerance is appropriate. - Mismatch response now also returns expected/received raw values + lengths, so the wizard (or operator inspecting the response) can see the actual diff. Frontend (components/PluginUninstall.tsx) - Same normalisation client-side, applied both to the gate (typedPhrase === required check) and to the body sent to the backend. The user no longer has to hunt for an invisible character. Direct push to develop per Davidson's instruction (no PR needed for this small UX fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d58c492 commit 8b8b88c

2 files changed

Lines changed: 30 additions & 3 deletions

File tree

dashboard/backend/routes/plugins.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1274,11 +1274,28 @@ def uninstall_plugin(slug: str):
12741274
_body = request.get_json(force=True, silent=True) or {}
12751275
_phrase_required = (_safe_uninstall_spec.get("user_confirmation") or {}).get("typed_phrase", "")
12761276
_phrase_given = _body.get("confirmation_phrase", "")
1277-
if _phrase_required and _phrase_given != _phrase_required:
1277+
# Normalize both sides before comparing so the user is not punished for
1278+
# invisible characters that the browser silently inserts (NBSP from
1279+
# copy-paste, trailing whitespace from autofill, NFD vs NFC composition).
1280+
# The phrase is a human-typed confirmation token, not a cryptographic key —
1281+
# tolerance is appropriate. The raw values are still preserved for the
1282+
# error response so the operator can see the actual diff if needed.
1283+
import unicodedata
1284+
def _normalize_phrase(s: str) -> str:
1285+
return unicodedata.normalize("NFC", str(s or "")).strip().replace(" ", " ")
1286+
_required_norm = _normalize_phrase(_phrase_required)
1287+
_given_norm = _normalize_phrase(_phrase_given)
1288+
if _required_norm and _given_norm != _required_norm:
1289+
# Surface the actual diff so the UI can highlight the mismatch
1290+
# without the operator having to guess what went wrong.
12781291
return jsonify({
12791292
"error": "confirmation_phrase_mismatch",
12801293
"detail": f"Typed phrase must be exactly: {_phrase_required}",
12811294
"code": "bad_request",
1295+
"expected": _phrase_required,
1296+
"received": _phrase_given,
1297+
"expected_length": len(_required_norm),
1298+
"received_length": len(_given_norm),
12821299
}), 400
12831300

12841301
_exported_at = _body.get("exported_at", "")

dashboard/frontend/src/components/PluginUninstall.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,22 +61,32 @@ export default function PluginUninstall({
6161
const [uninstalling, setUninstalling] = useState(false)
6262
const [error, setError] = useState<string | null>(null)
6363

64+
// Normalize the same way the backend does (NFC + strip + collapse common
65+
// whitespace lookalikes) so a copy-paste with a trailing space or NBSP
66+
// doesn't fail the strict comparison silently.
67+
const normalize = (s: string) =>
68+
s.normalize('NFC').replace(/ /g, ' ').trim()
69+
6470
const requiredPhrase = safeUninstall?.user_confirmation?.typed_phrase ?? ''
71+
const requiredPhraseNorm = normalize(requiredPhrase)
6572
const checkboxLabel =
6673
safeUninstall?.user_confirmation?.checkbox_label ??
6774
'Tenho uma cópia dos dados exportados e assumo responsabilidade pela retenção legal.'
6875
const reason = safeUninstall?.reason ?? ''
6976
const preservedTables = safeUninstall?.preserved_tables ?? []
7077

71-
const phraseMatches = typedPhrase === requiredPhrase
78+
const typedPhraseNorm = normalize(typedPhrase)
79+
const phraseMatches = typedPhraseNorm === requiredPhraseNorm
7280
const passwordsMatch = zipPassword === zipPasswordConfirm && zipPassword.length >= 8
7381

7482
async function handleUninstall() {
7583
setUninstalling(true)
7684
setError(null)
7785
try {
7886
const body: Record<string, unknown> = {
79-
confirmation_phrase: typedPhrase,
87+
// Send the normalised value so the backend never sees stray NBSP or
88+
// surrounding whitespace from autofill / copy-paste.
89+
confirmation_phrase: typedPhraseNorm,
8090
zip_password: zipPassword,
8191
}
8292
await api.delete(`/plugins/${slug}`, body)

0 commit comments

Comments
 (0)