fix(whatsapp): allow connecting Meta test numbers without a PIN

The WhatsApp config POST handler hard-failed with a 400 whenever a
number needed registration (first save or number change) and no
6-digit two-step-verification PIN was supplied. Meta test numbers
(Developer Console) are pre-registered by Meta and expose no PIN to
set, so test-number users could never complete setup (issue #242).

The PIN-based /{phone_number_id}/register call only governs inbound
webhook routing for production numbers under a shared WABA (issue
#136) — it has nothing to do with credential validity or outbound
sends. So treat it as best-effort: when no PIN is supplied, skip
/register, save the (already Meta-verified) credentials as connected,
and leave registered_at null. The response now reports `registered`
accurately and adds `registration_skipped` so the UI can react.

UI: the save handler no longer falsely claims the number is "Live"
on a skipped registration — it points at the existing "Not
registered" banner, which already offers a PIN-retry path. The PIN
field is relabelled (optional) with copy explaining it's only for
production inbound routing and that test numbers have no PIN.

Fixes #242

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-15 17:01:36 +03:00
parent 5b6fe3d677
commit 3b924c85bc
No known key found for this signature in database
2 changed files with 58 additions and 35 deletions

View File

@ -284,41 +284,48 @@ export async function POST(request: Request) {
// Step 1: register the phone number for inbound webhooks. // Step 1: register the phone number for inbound webhooks.
// //
// Required on first save AND whenever the user supplies a fresh // Attempted on first save AND whenever the user supplies a fresh
// PIN (e.g. they rotated the 2FA PIN in Meta Manager). Skipped // PIN (e.g. they rotated the 2FA PIN in Meta Manager). Skipped
// when the same number is already registered and no PIN was // when the same number is already registered and no PIN was
// supplied — re-registering an already-active number with a // supplied — re-registering an already-active number with a
// stale PIN would actually fail and undo the active subscription. // stale PIN would actually fail and undo the active subscription.
let registeredAt: string | null = existing?.registered_at ?? null let registeredAt: string | null = existing?.registered_at ?? null
let registrationError: string | null = null let registrationError: string | null = null
// True when registration was deliberately skipped because no PIN
// was supplied (see below). Distinct from registrationError — this
// is not a failure, just an incomplete-but-valid save.
let registrationSkipped = false
const needsRegistration = !sameNumber || (typeof pin === 'string' && pin.length > 0) const needsRegistration = !sameNumber || (typeof pin === 'string' && pin.length > 0)
if (needsRegistration) { if (needsRegistration) {
if (!pin) { if (!pin) {
return NextResponse.json( // No PIN provided. Meta TEST numbers (Developer Console) are
{ // pre-registered by Meta and expose no two-step verification
error: // PIN to set, so requiring one made them impossible to connect
'Two-step verification PIN is required to subscribe this number to wacrm. ' + // (issue #242). The /register + PIN step only matters for
'Set a 6-digit PIN in Meta WhatsApp Manager → Phone Numbers → Two-step verification, then paste it below.', // production numbers under a shared WABA (issue #136), so treat
}, // it as best-effort: skip it, save the (already Meta-verified)
{ status: 400 } // credentials as connected, and leave registered_at null. The
) // UI surfaces a separate "Not registered" banner with a path to
} // add a PIN later for users who do need inbound webhook routing.
try { registrationSkipped = true
await registerPhoneNumber({ } else {
phoneNumberId: phone_number_id, try {
accessToken: access_token, await registerPhoneNumber({
pin, phoneNumberId: phone_number_id,
}) accessToken: access_token,
registeredAt = new Date().toISOString() pin,
} catch (err) { })
registrationError = registeredAt = new Date().toISOString()
err instanceof Error ? err.message : 'Unknown Meta API error' } catch (err) {
console.error('Phone number /register failed:', registrationError) registrationError =
// We deliberately fall through and still save the row so the err instanceof Error ? err.message : 'Unknown Meta API error'
// user can retry without re-entering everything. The UI console.error('Phone number /register failed:', registrationError)
// surfaces `last_registration_error` so they see WHY it's // We deliberately fall through and still save the row so the
// not actually live yet. // user can retry without re-entering everything. The UI
// surfaces `last_registration_error` so they see WHY it's
// not actually live yet.
}
} }
} }
@ -410,7 +417,12 @@ export async function POST(request: Request) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
saved: true, saved: true,
registered: true, registered: registeredAt != null,
// Credentials are valid and saved, but inbound webhook
// registration was skipped because no PIN was supplied (e.g. a
// Meta test number). The UI shows the "Not registered" banner
// rather than claiming the number is fully live.
registration_skipped: registrationSkipped,
phone_info: phoneInfo, phone_info: phoneInfo,
}) })
} catch (error) { } catch (error) {

View File

@ -233,6 +233,16 @@ export function WhatsAppConfig() {
`Saved, but Meta couldn't register the number: ${data.registration_error}`, `Saved, but Meta couldn't register the number: ${data.registration_error}`,
{ duration: 12000 }, { duration: 12000 },
); );
} else if (data.registration_skipped) {
// Credentials saved + verified, but /register was skipped
// because no PIN was supplied (e.g. a Meta test number).
// Don't claim the number is "Live" — point at the
// Registration status banner instead.
toast.success(
'Credentials saved and verified. Inbound registration was skipped (no PIN) — see Registration status below.',
{ duration: 10000 },
);
setPin('');
} else { } else {
toast.success( toast.success(
data.phone_info?.verified_name data.phone_info?.verified_name
@ -604,9 +614,7 @@ export function WhatsAppConfig() {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300"> <Label className="text-slate-300">
Two-step verification PIN Two-step verification PIN
{!isRegistered && ( <span className="ml-1 text-slate-500">(optional)</span>
<span className="ml-1 text-red-400">*</span>
)}
</Label> </Label>
<Input <Input
type="text" type="text"
@ -620,16 +628,19 @@ export function WhatsAppConfig() {
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 tracking-widest" className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 tracking-widest"
/> />
<p className="text-xs text-slate-500 leading-relaxed"> <p className="text-xs text-slate-500 leading-relaxed">
Required the first time you connect a number, and any Needed only to wire <strong className="text-slate-300">inbound</strong> messages
time you swap to a different number. Set it in{' '} for a <strong className="text-slate-300">production</strong> number. Set it in{' '}
<strong className="text-slate-300"> <strong className="text-slate-300">
Meta Business Manager → WhatsApp Accounts → Phone Meta Business Manager → WhatsApp Accounts → Phone
Numbers → Two-step verification Numbers → Two-step verification
</strong> </strong>
. Without this PIN, Meta saves your credentials but , then paste it here so wacrm can subscribe the number —
won&apos;t actually route inbound messages to wacrm — otherwise Meta routes inbound events to whichever app
the symptom that hits second numbers under a shared last claimed it (the symptom that hits second numbers
WABA. Leave blank to keep an existing registration under a shared WABA).{' '}
<strong className="text-slate-300">Meta test numbers</strong> have no
PIN and are pre-registered — leave this blank for them.
Leaving it blank also keeps an existing registration
untouched. untouched.
</p> </p>
</div> </div>