#Edge-case matrix
For each case: trigger → backend response → frontend behaviour → user message → recovery. Status codes follow the OSS conventions where Central has no API yet Assumption. Messages are i18n keys in the build (the English shown is the source text).
| # | Case | Trigger | Backend response | Frontend behaviour | User message | Recovery |
|---|---|---|---|---|---|---|
| 1 | Invalid login | Wrong email/password | 422/401 generic | Clear password, focus it | "Incorrect email or password." | Retry / forgot password |
| 2 | Unverified account | Login before verifying | Login succeeds — corrected 2026-10-09. The backend moved 2.2 / 2.3 to the V7 flow: an unverified user can log in and use the panel. email_verified comes back with the user | A reminder banner, not a block — with Resend | "Please verify your email address." | Resend link |
| 2z | An action that needs a verified email | Only four: sending / resending an invitation, ownership transfer (4.7), creating a server (8.8), promo credits (Phase 7) | 403 code: email_not_verified | Refuse just that action and point at Resend | The backend's message | Guard these four, not the app. An earlier version of this row blocked login itself, which is wrong |
| 2a | Unverified state cleared another way | Anywhere after login | Three routes out, as of 2026-10-09: the verify link (2.2), a password reset (2.5) and a social login (2.4) | — | Read the verified flag from the API after every login and on session restore — never infer it from how the account was created, and never cache it for the session. A banner that outlives the state it describes is the bug these routes invite | — |
| 2b | Provider returns an unverified email | Google / GitHub login | Linking or creating requires a provider-verified email (2.4, 2026-10-09), so the sign-in stops | No retry | Belongs in the /login?error=<code> list. The message must be actionable — name the cause and the fix (the address is not verified with the provider) rather than a generic failure. Code and wording still to come from the backend Open question | Provider |
| 3 | Banned / locked | Login | 403 code: account_banned (corrected 2026-10-09 — was a guess, banned) | No retry | "This account is locked. Contact support." | Support |
| 3b | IP whitelist on, and the user's address keeps changing | Login | Exact IP matching (2.8, 2026-10-09) means every new address is blocked afresh — a dynamic home IP or mobile connection needs a new emailed approval each time, then another login | No retry | The blocked screen must not imply a one-off. A new approval email only once per 5 minutes per IP, so no resend button that silently does nothing. The warning belongs on the switch in Account → IP whitelist (3.10), before it is turned on | Account owner |
| 3a | Banned account tries a password reset | Forgot / reset password | No email is sent; an existing link is refused (2.5, 2026-10-09) | No retry | Forgot form: the generic "if an account exists, we sent a link" — saying banned here would reveal account state to anyone typing an email. Reset screen: "This link can no longer be used" — not expired, because it has not timed out Open question (the backend does not state either message) | Support |
| 4 | Unknown IP (whitelist on) | Login from a new IP | 403 code: ip_not_approved | Approval-sent screen | "We emailed you a link to approve this IP." | Click link (24 h) |
| 5 | 2FA wrong code | Bad code | 422, attempts left | Shake + counter | "That code is wrong. N tries left." | Resend / backup code |
| 6 | Session expired | Token expired | 401 | Refresh once, else to login | "Your session expired. Please log in again." | Log in → return to next |
| 7 | No organization | Login with none | 200 empty list | Route to create organization | "Create your organization to get started." | Create |
| 8 | Organization deleted | Current org gone | 404 on scoped calls | Clear current, switch to main/first | "That organization is no longer available." | Pick another |
| 9 | Member removed | Membership revoked | 403/404 | Same as 8 | "You no longer have access to | Ask an owner |
| 10 | Permission denied | Action without grant | 403 code: forbidden | 403 state / toast, refresh permissions | "You don't have permission to do this." | Ask an owner |
| 11 | Plan expired | Subscription expired | 403 code: subscription_expired (V7 sends 500 + message) | Banner, actions disabled | "Your subscription has expired. Renew to continue." | Renew |
| 12 | Subscription cancelled | Cancel flag set | 200 status cancelled | "Ends on | "Your plan ends on | Resume |
| 13 | Payment failed | Gateway declined | Transaction failed | Return page shows failure | "The payment didn't go through. Nothing was charged." | Retry / other method |
| 14 | Payment pending | Gateway slow | Transaction pending | Poll, then "processing" | "We're confirming your payment. This can take a few minutes." | Wait / email |
| 15 | Not enough credit | Plan change > balance | 422 code: insufficient_credit + amount | Show top-up with the amount | "Add $N to your wallet to continue." | Top up |
| 16 | Usage limit reached | Add server over limit | 403 code: plan_limit_reached | Upgrade dialog | "Your plan allows N servers." | Upgrade / disconnect |
| 17 | Server offline | OSS timeout | 504 code: server_unreachable | Unreachable state | "This server isn't responding (last seen …)." | Retry / check server |
| 18 | Invalid key | OSS 401 on connect | 422 code: invalid_server_key | Field error | "This Central key isn't valid." | Copy the key again |
| 19 | Key revoked later | OSS 401 on use | 409/424 code: server_unauthorized | Reconnect banner | "Central can no longer access this server." | Paste the new key |
| 20 | Duplicate server | Already linked | 409 code: server_already_connected | Field error | "This server is already connected." | Open the existing one |
| 21 | Version mismatch | OSS older than minimum | 422 code: oss_version_unsupported | Explain + docs link | "Update the panel to vX.Y or newer." | Update the panel |
| 22 | Provider failure | Provider API error | 502 code: provider_error + provider message | Keep the form | " | Fix / retry |
| 23 | Provider token expired | OAuth expired, refresh failed | 401 code: provider_reauth | Reconnect button | "Reconnect your | OAuth again |
| 24 | VPS creation failure | Create/boot/install failed | Job status failed + step | Progress shows the failed step | "Server creation failed at | Retry / delete VPS |
| 25 | Blueprint deployment failure | Add-on run failed / steps failed | Run failed or completed: false | Per-step results | "Blueprint finished with N failed steps." | Retry / edit |
| 26 | Add-on not installed / no licence | OSS 404/403 add-on codes | Passed through | Install/buy hint | "WP Toolkit isn't installed / licensed on this server." | Install / buy |
| 27 | API timeout | Central slow | Client timeout | Error state + retry | "This is taking too long." | Retry |
| 28 | Network error | Offline | No response | Offline banner, keep form data | "You appear to be offline." | Retry when online |
| 29 | 404 | Wrong URL / other org's resource | 404 | not-found page | "This page doesn't exist." | Back / dashboard |
| 30 | 500 | Bug | 500 + reference | Error state + reference | "Something went wrong. Reference: …" | Retry / support |
| 31 | 429 | Too many requests | 429 + Retry-After | Disable action, countdown | Show the backend's own sentence — "Too many attempts. Please try again in N seconds." (published 2026-10-09, already translated, and it carries the number) | Wait |
| 31a | 429 on a sensitive form | Password change, delete account (5 / min), email-change request (3 / 10 min) | 429 + Retry-After | Button disabled with the seconds counting down; the form keeps what was typed | The backend's message | These limits are far tighter than ordinary reads (60 / min), so a user who mistypes their current password a few times will hit one. Never clear the form on a 429 |
| 31k | Your default organization was deleted by its owner | A member whose default was someone else's organization | Their default moves on its own (backend, 4.2) | On the next load the switcher shows a different organization | Tell them, once: " | The user changed nothing and their context moved — silently switching them is how people end up acting in the wrong organization |
| 31l | An Admin opens Delete organization | Admin, not owner | 403 | Hide or disable the action for Admins | — | Do not let it fail on click — the backend is owner-only |
| 31h | IP whitelist turned off | The switch goes off | 200 — "All whitelisted IPs were removed." | Confirm first: the list is deleted, not paused | Our own confirmation | Turning it back on does not restore anything — the user would have to add every address again |
| 31i | Removed the IP you are using | Deleting your own row from the whitelist | 200 with removed_current_ip: true | Warn, but allow it | "This session keeps working, but your next login from this address will need an emailed approval." | Do not block it — the backend allows it and tells us it happened |
| 31j | 100 IPs reached | Adding the 101st address | 422 | Disable Add, show the count | The backend's "You can whitelist at most 100 IP addresses." | Show how many are used before they hit it |
| 31e | Authenticator setup ran out of time | The QR sat on screen longer than 10 minutes before the code was entered | 422 on confirm, with its own message | Close the step and offer Start again | The backend's "The setup has expired…" | Fail cleanly — a fresh QR is one click away, so never leave a dead QR on screen |
| 31f | Last backup code used | available reaches 0 (or 1) in the 2FA status | — | Warn on the Security tab and offer Create new codes | Our own line | Nothing breaks yet, which is why it needs saying early — a user with no codes left and a lost phone is locked out, and the codes cannot be read back |
| 31g | Turning 2FA off while the authenticator is on | Off is one action for both | 200 — method becomes null | Confirm first: it also removes the authenticator and every backup code | Our own confirmation | Say what else goes — the user is likely thinking only of the email codes |
| 31c | Session already gone | Logging out a device that signed out by itself, or whose token expired | 404 | Refresh the list; treat it as done | "That device is already signed out." | Not an error — the device is gone either way, which is what the user wanted |
| 31d | Session from before the feature existed | Sessions started before 2026-10-09 | Browser / platform Unknown, IP / method / sign-in time null | Blank cells; the row still logs out normally | — | Design for missing values rather than printing "Unknown" four times across one row |
| 31b | Email-change link opened elsewhere | The confirm link is opened on a different device, or after signing out | No session at all | /confirm-email-change works signed out, then points to login | The backend's result message | By design — the backend's confirm call takes no token. The page must not redirect a signed-out visitor to login before confirming |
| 32 | Concurrent edit | Two admins edit a role | 409 or last-write-wins Open question | Reload prompt | "This was changed by someone else." | Reload |
| 33 | Owner removes self / last owner | Remove member | 422 | Refuse | "An organization needs an owner." | Transfer ownership Open question |
| 34 | Delete org with servers | Delete | 422 (V7: 500 + message) | Link to servers | "Disconnect all servers first." | Disconnect |
| 35 | Delete protection on | Any delete, anywhere | 423 (corrected 2026-10-09 — was a guessed 403; 2026-10-27 Pair 2: now on all 8 Application-panel delete actions too, not on unban): every delete route carries a delete.protection check and answers 423 "turn off delete protection first". V7 only checked this in the frontend, so in V8 this can arrive even where no guard was built | Explain the setting and link to it | "Delete protection is on." | Turn it off in Security |
| 36 | Invitation expired / used | Open the link | 410/404 | Invite-invalid page | "This invitation is no longer valid." | Ask for a new invite |
| 37 | Locale missing | Key not translated | — | English fallback | (English text) | Translate |