Commit 61c7d27c143 for woocommerce
commit 61c7d27c143d8c39c0ad1bc5c870be6236bad5b8
Author: Jorge Mucientes <jorgemucientes@gmail.com>
Date: Fri May 29 16:28:54 2026 +0200
Add mobile app QR login (#65111)
* WC-Core 1: Replace WPCOM gate with manage_woocommerce capability (WOOMOB-2764) (#64302)
* Add QR code direct login for mobile app via Application Passwords
Replace the current QR code flow (which only pre-fills credentials)
with a direct login flow for users with linked WordPress.com accounts.
When a WPCOM-authenticated user opens the mobile app modal, a
time-limited QR code is generated that the mobile app can scan
to receive an Application Password, completing login without
requiring manual password entry.
Backend:
- New REST endpoint POST /wc-admin/mobile-app/qr-login-token
(generates short-lived token, requires auth)
- New REST endpoint POST /wc-admin/mobile-app/qr-login-exchange
(exchanges token for Application Password, no auth needed)
- Rate limiting, HTTPS requirement, one-time token use
Frontend:
- New useQRLoginToken hook for token lifecycle management
- New QRDirectLoginCode component with expiry countdown
- Updated MobileAppLoginStepper to show direct-login QR
for WPCOM users, keeping existing flow for others
* Replace WPCOM gate with manage_woocommerce on mobile-app QR login token endpoint
* Fix CI failures: smart quotes, JS/PHP lint, PHPStan docblocks
- Replace Unicode smart quotes with ASCII apostrophes in the
MobileAppLoginStepper step definitions so the file parses under
TypeScript/ESLint and unblocks the JS build pipeline.
- Drop two unused imports (sprintf, SendMagicLinkButton) that ESLint
flagged after the smart-quote fix.
- Collapse single-line JSX in QRDirectLoginCode per prettier.
- Make MobileAppQRLogin PHPStan-clean: declare strict_types, add
generic type args to WP_REST_Request parameters, add a return type
annotation for register_routes, and remove the @extends tag that
the docblock parser refused to accept on the new file.
- Silence a false-positive WordPress.Security.ValidatedSanitizedInput
pair in the test fixture (server-global snapshotting for restore,
never used for processing).
* Address PR #64302 review feedback: HTTPS site URL safeguard + @testdox annotations
- Introduces `get_secure_site_url()` in the `MobileAppQRLogin` controller.
Both `generate_token()` and `exchange_token()` now refuse to return
credentials whose `siteUrl` would be `http://`, covering the proxy /
stale-`siteurl` scenario where `is_ssl()` is true but the canonical site
URL is still HTTP. A new `insecure_site_url` error (status 500) is
returned in that case.
Chose rejection over silent normalization because (a) the misconfig
typically affects other site functionality (reset-password emails,
canonical redirects), so failing loudly surfaces it, and (b) we cannot
verify from within a single request that the site actually serves
HTTPS on the same host.
- Adds regression tests for both the generation and exchange paths,
simulating a stale `siteurl` via a `pre_option_siteurl` filter.
- Replaces every descriptive test docblock in `MobileAppQRLoginTest`
with a concise `@testdox` annotation for better PHPUnit output.
* Check raw siteurl option, not get_site_url(), for HTTPS safeguard
`get_site_url()` passes its result through `set_url_scheme()`, which
overwrites the scheme to match `is_ssl()`. That means checking
`get_site_url()`'s scheme will always pass when the current request is
HTTPS, regardless of the raw `siteurl` option — defeating the whole
point of the safeguard.
Switch the check to `get_option('siteurl')`, which reflects actual
admin configuration (and what shows up in reset-password emails,
webhooks, canonical redirects). This makes the `insecure_site_url`
rejection fire in the exact proxy / stale-`siteurl` scenario the PR
review flagged, and unblocks the regression tests that simulate it
via a `pre_option_siteurl` filter (they were failing with 200 on CI
because `set_url_scheme()` was silently upgrading the test fixture).
* Harden mobile QR token exchange
* Harden QR login exchange: atomic claim + REMOTE_ADDR rate-limit key
- Atomically claim tokens via wp_cache_add() before reading the transient
so two concurrent exchanges of the same QR token can no longer each
mint an Application Password on persistent-cache backends (Redis/
Memcached, i.e. essentially every production WooCommerce host).
- Key the per-IP exchange rate limit on REMOTE_ADDR only. The previous
HTTP_X_FORWARDED_FOR fallback let an unauthenticated client choose a
fresh rate-limit bucket per request.
- Defensive sanitize_text_field() on the token even though the REST
sanitize_callback already covers it.
* QR login: tag credentials with stable app_id, log + localize AP failure
- Add a class constant APP_ID and pass it to
WP_Application_Passwords::create_new_application_password() so admins
can identify and bulk-revoke QR-issued credentials from the
Application Passwords screen.
- On AP-creation failure, log the underlying WP_Error to the
mobile-app-qr-login logger source and return a localized,
user-friendly message instead of leaking the raw (English, technical)
error string into the HTTP response body.
- Drop the stale "no longer required" wording from the class doc — this
is a brand-new class, the prior phrasing only made sense as a diff.
* QR login hook: drop dead WPCOM branch, validate response payload
- Remove the wpcom_account_required error branch — it was a leftover
from the prior gate. The new MobileAppQRLogin controller never emits
that error code.
- Validate qr_url is a non-empty string and expires_at is a finite
future timestamp before transitioning to READY. A malformed or
filtered response now drops cleanly into the existing ERROR state
instead of rendering an empty QR code with a NaN countdown.
* QR direct login: a11y live region, fix analytics asymmetries
- Wrap LOADING / ERROR / EXPIRED status text in role="status"
aria-live="polite" so screen readers are notified when the step
changes, per WC coding guidelines.
- Mark the per-second countdown in READY with aria-live="off" so it
doesn't re-announce every tick.
- Fire mobile_app_qr_direct_login_displayed only once the QR code is
actually rendered (state === READY), instead of unconditionally on
mount. Prevents users who only see LOADING or ERROR from being
counted in the display funnel.
- Emit mobile_app_qr_direct_login_refreshed from the ERROR-state "Try
again" button so refresh instrumentation is symmetric with the
EXPIRED state.
* QR direct login: prettier formatting fix
* Normalize all SSL server indicators in MobileAppQRLogin tests
Capture and restore SERVER_PORT and HTTP_X_FORWARDED_PROTO alongside
HTTPS so leftover globals from PHPUnit's runner or earlier tests cannot
make a plain-HTTP fixture pass is_ssl(). force_https(false) now also
unsets SERVER_PORT and HTTP_X_FORWARDED_PROTO.
* Emit telemetry on QR direct-login error state
Mirror the displayed/refreshed events with mobile_app_qr_direct_login_failed
so funnel analysis has symmetric attribution for the error path. Tracks
once per entry into ERROR; resets on transition back to LOADING/READY so
a later failure on the same mount is recorded again.
* Lint: prettier + phpcs alignment on Task 1 commits
- prettier wants the errorTrackedRef condition on a single line
- phpcs wants original_https/server_port/remote_addr to align with the
longer original_http_x_forwarded_proto sibling
* Harden QR login token exchange
* Add a single QR login changelog entry
Replace the six implementation-detail changelog fragments with one user-facing feature entry.
This addresses chihsuan's feedback because changelog flattening should describe the released QR login feature once instead of preserving every development iteration as separate release notes.
* Use WordPress interpolation in QR login copy
Replace @automattic/interpolate-components with createInterpolateElement from @wordpress/element for the QR login FAQ copy.
This addresses chihsuan's feedback because new code should prefer the official WordPress interpolation helper and avoid adding another usage of the older Automattic helper.
* Move QR login tracking to token transitions
Add onReady and onError callbacks to useQRLoginToken and fire QR direct-login tracking from the fetchToken success and failure paths.
This addresses chihsuan's feedback because telemetry now runs where the READY and ERROR transitions are caused, which removes state-watching effects and avoids future events with stale or empty error_message values.
* Prefix QR login CSS classes
Rename the QR direct-login component classes to use the woocommerce-qr-direct-login prefix.
This addresses chihsuan's feedback because new Admin UI class names should be WooCommerce-prefixed to match project conventions and avoid generic selector collisions.
* Use wc_rate_limits for QR login throttles
Add a QRLoginRateLimits helper backed by the wc_rate_limits table and replace the QR login controller's transient-backed generation, IP, invalid-token, and valid-token counters.
This addresses chihsuan's feedback because the QR login exchange path is a security-sensitive endpoint; atomic persisted counters avoid transient get/set races, avoid fail-open cache writes, and reuse WooCommerce's existing rate-limit cleanup table.
* Harden QR exchange rate limit ordering
* Track QR login error code instead of localized message
Replaces the `error_message` Tracks property with `error_code` so funnel
dashboards aggregate failures by the stable API code (e.g. `ssl_required`,
`rate_limit_exceeded`) rather than per-locale strings. The user-facing
error message in the UI is unchanged.
* Gate QR exchange on create_app_password capability
Mirror the permission check in WP core's
WP_REST_Application_Passwords_Controller::create_item_permissions_check()
before minting the Application Password in the exchange flow. Caps or
per-user availability filters may have changed in the up-to-5-minute
window between token generation and exchange, so re-check at the point
where credentials are actually issued.
Adds a test that denies create_app_password via map_meta_cap and asserts
the response code, that no AP is created, that the token transient is
preserved, and that the claim is released.
* Fire QR _displayed event only once per mount
The PR description states the displayed event is recorded on first
READY render only, but onReady fires on every successful fetchToken,
so clicking Try again or Generate new code emitted both _refreshed
and _displayed for the same surface and over-counted first-displays
in the funnel. Guard the emit with a useRef so _displayed fires once
per mount while _refreshed continues to fire on each manual refresh.
* WC-Core 2: Always show QR direct login in mobile app modal (WOOMOB-2765) (#64318)
* Always show QR direct login in mobile-app modal stepper
Removes the WPCOM-gated fallback (username + site URL QR) in step 2 of
the mobile-app onboarding stepper. The direct-login QR is now the
primary path for every admin / shop manager (the capability gate landed
server-side in the previous commit). When the user also has a linked
WordPress.com account, the existing "Send the sign-in link" button is
rendered as a secondary CTA below the QR.
Adds Jest coverage for the four scenarios called out in WOOMOB-2765:
admin without Jetpack, admin with Jetpack fully connected, shop manager
without a linked account, and the QR error state.
* Drop 15.7 version note from QR and surface Jetpack error on magic-link failure
Two user-facing polishes for the mobile-app modal:
1. Remove the 'The app version needs to be 15.7 or above to sign in with
this link.' helper text from QRDirectLoginCode (the new two-stage QR
flow) and from the unused MobileAppLoginInfo fallback. The message
was carried over from the exploratory cherry-pick and no longer
reflects the current app requirements.
2. Improve error handling in useSendMagicLink so merchants see the
actual Jetpack error instead of a generic retry message. The full
response is now logged to the browser console for debugging, and
the user-facing notice appends response.message in parentheses so
support can distinguish a transient hiccup from a real Jetpack
configuration issue.
* Use Woo purple for the modal's sign-in button and resend link
Replace the default WordPress blue (#007cba) with Woo's signature purple
(#7f54b3) on:
- The 'Send the sign-in link' primary button in the modal's sign-in step.
Also adds a proper &:hover / &:focus selector with a darker purple
(#674399) and a smooth background-color transition. Previously the
:hover selector targeted descendants, not the button itself.
- The 'Send another link' link on the email-sent confirmation page.
Visually aligns the modal with the Woo admin accent already used by the
step indicator and the illustration panel.
* Revert modal button to WP blue and relocate FAQ below the magic-link button
Two small UX adjustments on the mobile-app modal, per design feedback:
1. Restore the default WordPress blue (#007cba) on the 'Send the sign-in
link' button and the 'Send another link' on the email-sent page.
The blue aligns with the rest of the admin's link-style CTAs; the
short-lived Woo-purple experiment is rolled back.
2. Move the 'Any troubles signing in? Check out the FAQ.' link out of
QRDirectLoginCode and into the stepper, rendered as the last element
in step 2. When the magic-link button is visible (Jetpack + WP.com
linked), the FAQ now sits below it; when it's hidden, the FAQ still
follows the QR. This keeps troubleshooting guidance at the bottom of
the flow regardless of branch.
QRDirectLoginCode is now purely the QR + countdown, with no opinions
about what goes around it. The standalone page's own FAQ link is
unaffected — it renders independently.
* WC-Core 3: Update useQRLoginToken error handling for capability-based permissions (WOOMOB-2766) (#64319)
* Always show QR direct login in mobile-app modal stepper
Removes the WPCOM-gated fallback (username + site URL QR) in step 2 of
the mobile-app onboarding stepper. The direct-login QR is now the
primary path for every admin / shop manager (the capability gate landed
server-side in the previous commit). When the user also has a linked
WordPress.com account, the existing "Send the sign-in link" button is
rendered as a secondary CTA below the QR.
Adds Jest coverage for the four scenarios called out in WOOMOB-2765:
admin without Jetpack, admin with Jetpack fully connected, shop manager
without a linked account, and the QR error state.
* Drop 15.7 version note from QR and surface Jetpack error on magic-link failure
Two user-facing polishes for the mobile-app modal:
1. Remove the 'The app version needs to be 15.7 or above to sign in with
this link.' helper text from QRDirectLoginCode (the new two-stage QR
flow) and from the unused MobileAppLoginInfo fallback. The message
was carried over from the exploratory cherry-pick and no longer
reflects the current app requirements.
2. Improve error handling in useSendMagicLink so merchants see the
actual Jetpack error instead of a generic retry message. The full
response is now logged to the browser console for debugging, and
the user-facing notice appends response.message in parentheses so
support can distinguish a transient hiccup from a real Jetpack
configuration issue.
* Use Woo purple for the modal's sign-in button and resend link
Replace the default WordPress blue (#007cba) with Woo's signature purple
(#7f54b3) on:
- The 'Send the sign-in link' primary button in the modal's sign-in step.
Also adds a proper &:hover / &:focus selector with a darker purple
(#674399) and a smooth background-color transition. Previously the
:hover selector targeted descendants, not the button itself.
- The 'Send another link' link on the email-sent confirmation page.
Visually aligns the modal with the Woo admin accent already used by the
step indicator and the illustration panel.
* Revert modal button to WP blue and relocate FAQ below the magic-link button
Two small UX adjustments on the mobile-app modal, per design feedback:
1. Restore the default WordPress blue (#007cba) on the 'Send the sign-in
link' button and the 'Send another link' on the email-sent page.
The blue aligns with the rest of the admin's link-style CTAs; the
short-lived Woo-purple experiment is rolled back.
2. Move the 'Any troubles signing in? Check out the FAQ.' link out of
QRDirectLoginCode and into the stepper, rendered as the last element
in step 2. When the magic-link button is visible (Jetpack + WP.com
linked), the FAQ now sits below it; when it's hidden, the FAQ still
follows the QR. This keeps troubleshooting guidance at the bottom of
the flow regardless of branch.
QRDirectLoginCode is now purely the QR + countdown, with no opinions
about what goes around it. The standalone page's own FAQ link is
unaffected — it renders independently.
* Update useQRLoginToken error handling for capability-based permissions
The backend QR login endpoint no longer returns `wpcom_account_required`
after WOOMOB-2764 switched the gate from WordPress.com connection to the
`manage_woocommerce` capability. Update the hook to:
- Drop the obsolete `wpcom_account_required` branch.
- Handle `woocommerce_rest_cannot_view` (the REST permission error the
capability check now surfaces) with a clear, actionable message.
- Handle `application_passwords_unavailable` explicitly instead of
falling through to the generic message.
- Keep `ssl_required` and `rate_limit_exceeded` branches; tighten the
rate-limit copy.
Add hook tests covering the state machine, countdown timer, all
error-code mappings, refetch-after-expiry, and the no-state-update-on-
unmount path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add changelog entry for WOOMOB-2766
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Clear stale mobile QR token state
* Replace circular AP-disabled hint with a docs link
When the QR login token endpoint returns application_passwords_unavailable
the merchant is already an admin or shop manager — telling them to ask a
site administrator is circular. Drop that sentence and link to the
WordPress application passwords docs instead so they can figure out
which constant or plugin disabled them.
Bumps the hook contract: errorMessage is now ReactNode (so cases can
embed inline links via interpolateComponents) and a new errorCode field
mirrors the REST code for funnel attribution. Renames useQRLoginToken.ts
to .tsx to host the JSX.
QRDirectLoginCode telemetry now sends the stable error_code as the
primary funnel attribution field; error_message stays as a best-effort
string fallback (empty string for ReactNode messages).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* WC-Core 4: New standalone wc-admin page for mobile app QR login (WOOMOB-2767) (#64335)
* Always show QR direct login in mobile-app modal stepper
Removes the WPCOM-gated fallback (username + site URL QR) in step 2 of
the mobile-app onboarding stepper. The direct-login QR is now the
primary path for every admin / shop manager (the capability gate landed
server-side in the previous commit). When the user also has a linked
WordPress.com account, the existing "Send the sign-in link" button is
rendered as a secondary CTA below the QR.
Adds Jest coverage for the four scenarios called out in WOOMOB-2765:
admin without Jetpack, admin with Jetpack fully connected, shop manager
without a linked account, and the QR error state.
* Drop 15.7 version note from QR and surface Jetpack error on magic-link failure
Two user-facing polishes for the mobile-app modal:
1. Remove the 'The app version needs to be 15.7 or above to sign in with
this link.' helper text from QRDirectLoginCode (the new two-stage QR
flow) and from the unused MobileAppLoginInfo fallback. The message
was carried over from the exploratory cherry-pick and no longer
reflects the current app requirements.
2. Improve error handling in useSendMagicLink so merchants see the
actual Jetpack error instead of a generic retry message. The full
response is now logged to the browser console for debugging, and
the user-facing notice appends response.message in parentheses so
support can distinguish a transient hiccup from a real Jetpack
configuration issue.
* Use Woo purple for the modal's sign-in button and resend link
Replace the default WordPress blue (#007cba) with Woo's signature purple
(#7f54b3) on:
- The 'Send the sign-in link' primary button in the modal's sign-in step.
Also adds a proper &:hover / &:focus selector with a darker purple
(#674399) and a smooth background-color transition. Previously the
:hover selector targeted descendants, not the button itself.
- The 'Send another link' link on the email-sent confirmation page.
Visually aligns the modal with the Woo admin accent already used by the
step indicator and the illustration panel.
* Revert modal button to WP blue and relocate FAQ below the magic-link button
Two small UX adjustments on the mobile-app modal, per design feedback:
1. Restore the default WordPress blue (#007cba) on the 'Send the sign-in
link' button and the 'Send another link' on the email-sent page.
The blue aligns with the rest of the admin's link-style CTAs; the
short-lived Woo-purple experiment is rolled back.
2. Move the 'Any troubles signing in? Check out the FAQ.' link out of
QRDirectLoginCode and into the stepper, rendered as the last element
in step 2. When the magic-link button is visible (Jetpack + WP.com
linked), the FAQ now sits below it; when it's hidden, the FAQ still
follows the QR. This keeps troubleshooting guidance at the bottom of
the flow regardless of branch.
QRDirectLoginCode is now purely the QR + countdown, with no opinions
about what goes around it. The standalone page's own FAQ link is
unaffected — it renders independently.
* Update useQRLoginToken error handling for capability-based permissions
The backend QR login endpoint no longer returns `wpcom_account_required`
after WOOMOB-2764 switched the gate from WordPress.com connection to the
`manage_woocommerce` capability. Update the hook to:
- Drop the obsolete `wpcom_account_required` branch.
- Handle `woocommerce_rest_cannot_view` (the REST permission error the
capability check now surfaces) with a clear, actionable message.
- Handle `application_passwords_unavailable` explicitly instead of
falling through to the generic message.
- Keep `ssl_required` and `rate_limit_exceeded` branches; tighten the
rate-limit copy.
Add hook tests covering the state machine, countdown timer, all
error-code mappings, refetch-after-expiry, and the no-state-update-on-
unmount path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add changelog entry for WOOMOB-2766
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Clear stale mobile QR token state
* Replace circular AP-disabled hint with a docs link
When the QR login token endpoint returns application_passwords_unavailable
the merchant is already an admin or shop manager — telling them to ask a
site administrator is circular. Drop that sentence and link to the
WordPress application passwords docs instead so they can figure out
which constant or plugin disabled them.
Bumps the hook contract: errorMessage is now ReactNode (so cases can
embed inline links via interpolateComponents) and a new errorCode field
mirrors the REST code for funnel attribution. Renames useQRLoginToken.ts
to .tsx to host the JSX.
QRDirectLoginCode telemetry now sends the stable error_code as the
primary funnel attribution field; error_message stays as a best-effort
string fallback (empty string for ReactNode messages).
* Add standalone wc-admin mobile-app-login page
A new page at /wp-admin/admin.php?page=wc-admin&path=/mobile-app-login
for merchants who already have the Woo mobile app installed and want
to sign in via QR scan without going through the onboarding modal.
- Single-step, scan-first UX with a manual refresh control
- Reuses <QRDirectLoginCode /> and useQRLoginToken from the onboarding
modal (no modifications to those shared pieces)
- No Jetpack / WPCOM branching on this page (the email magic-link CTA
stays on the onboarding modal only)
- Layout kept single-column so the WordPress.com multi-store flow can
be added later as a secondary CTA without a structural rewrite
Tests cover the render path, the refresh control, the FAQ link, the
QR error state, and a regression guard for the 'no magic-link button'
rule.
* Limit standalone QR login refresh
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* WC-Core 5: QR mobile-app login — in-app confirmation, revoke, persistent renew (#64456)
* Add QR mobile-app login confirmation, revoke, and persistent renew
Layer a stateful confirmation flow on top of the QR mobile-app sign-in
introduced in PR #64302 (Task 1) and the standalone page added in WOOMOB-2767
(Task 4). Both surfaces — the homescreen onboarding modal and the standalone
/mobile-app-login page — share QRDirectLoginCode and useQRLoginToken, so
every change here lights up in both places automatically.
Backend (MobileAppQRLogin.php):
- New GET /wc-admin/mobile-app/qr-login-status endpoint, polled by wc-admin
while the QR is on screen. Returns pending / consumed / expired and (on
consumed) the AP UUID, AP name, and the device that signed in. The current
user must own the token; cross-user reads are reported as expired so the
endpoint can't be used to probe other users' state.
- New DELETE /wc-admin/mobile-app/qr-login-revoke endpoint for the
"It wasn't you?" flow. Verifies AP ownership before deletion via
WP_Application_Passwords::get_user_application_password().
- Exchange now accepts an optional device payload (os, os_version, model,
app_version), whitelisted and length-capped at 64 chars per field.
- Application Password name is now "Woo Mobile · {model} · {YYYY-MM-DD}"
when device info is provided, with a graceful fallback to the existing
literal so older mobile clients keep working unchanged.
- After a successful exchange we persist a "consumed" transient keyed by
the same SHA-256 hash as the original token (TTL matches the modal's
lifetime) which is what the new status endpoint reads.
Frontend (useQRLoginToken + QRDirectLoginCode + new panels):
- Two new states (CONSUMED, REVOKED). Hook now polls the status endpoint
every ~2.5s while in READY and transitions to CONSUMED as soon as the
server reports the exchange happened.
- Plaintext token never leaves the hook closure (held in a ref).
- New revoke() function calls the new DELETE endpoint and transitions to
REVOKED on success; surfaces a clear fallback message on failure pointing
the merchant at Users → Profile → Application Passwords.
- New QRLoginConsumedPanel surfaces "Signed in successfully on {device}",
the OS/version/app-version subline, a Done button, and the
"It wasn't you? Revoke access" affordance.
- New QRLoginRevokedPanel confirms the AP was deleted.
- Persistent "Renew code" link next to the countdown so a merchant who
tabbed away can mint a fresh code without waiting for the 5-min expiry.
Tests:
- 10 new PHP test methods covering the new endpoints, the device payload
whitelist, the descriptive AP name, and the legacy-fallback path.
- Updated existing JS tests to reflect the new hook return shape and the
fact that successful generation now also issues a status poll.
WOOMOB-Task5
* Whitelist Android Build.BRAND on the QR exchange device payload
Adds brand to MobileAppQRLogin::DEVICE_PAYLOAD_KEYS and the REST schema.
The Android client (woocommerce-android) is being updated alongside this
change to send Build.BRAND (e.g. "google", "samsung") so the merchant
sees the manufacturer next to the model on the consumed/confirmation
panel and in the Application Password name. iOS doesn't have a direct
analogue; clients without the field just leave it absent and the legacy
fallback paths kick in unchanged.
WOOMOB-Task5
* Promote QR login confirmation to a dedicated third stepper step
The wc-admin homescreen modal currently renders the post-sign-in panel
inside step 2 (alongside the QR code, magic-link section, and FAQ link).
That made the success state feel like a side panel rather than the
natural end of the flow, and the magic-link CTA + FAQ stayed visible
under a panel that already meant 'you are signed in'.
This commit advances the merchant past those step-2 secondary controls
once the QR is exchanged:
- New 'Signed in successfully' step (key: 'third') in
MobileAppLoginStepper. The full three-step roadmap renders from the
start so the merchant sees what's coming.
- New QRLoginSuccessStep component with the bigger heading visual
hierarchy: 22px headline → device subline → 'It wasn't you?' line →
primary 'Revoke access' button. The revoke CTA opens a confirmation
dialog (Modal from @wordpress/components) explaining that the mobile
app will be signed out — a stray click can't silently revoke.
- New useRevokeQRLoginAccess hook owns the DELETE call lifecycle. Lives
separately from useQRLoginToken so the success step is self-contained
after the QR component is unmounted at the step-2 → step-3 transition.
- QRDirectLoginCode now accepts onConsumed + suppressInlinePanels props.
Standalone /mobile-app-login surface keeps the existing inline panel
behavior (no opt-in, no breaking change). The stepper uses both props
to lift the consumed snapshot up and render its own step-3 success UI.
- MobileAppLoginStepperPage tracks the consumed snapshot in local state
and derives the active step from it (third when present).
Brand (Build.BRAND) is now part of QRLoginDeviceInfo so the subline
reads e.g. 'Android 14 · Google Pixel 8 Pro · App version 24.7.0' on
Android clients sending the new payload.
Existing MobileAppLoginStepper tests updated for the new prop shape
(signInResult, onSignedIn) — all 21 mobile-app-modal Jest tests pass.
* Polish the QR login UI: meta column, smaller success heading, branded standalone page
- READY-state layout: the 'Code expires in' timer and 'Renew code'
button now sit to the right of the QR code, vertically centred, and
flow nicely on the standalone page (justify-center) and the modal
(left-aligned). Stacked layout for LOADING / EXPIRED / ERROR is
preserved by gating the new flex rules on a '--ready' modifier class.
- Step 3 success heading shrinks from 22px to 14px so it matches the
step-2 description size — the stepper label already provides the
large heading. The device subline drops the redundant model + brand
re-render so we don't say 'Pixel 10 Pro' twice.
- Standalone /mobile-app-login page picks up Woo branding: a 4px
purple accent stripe across the top of the card, a small
gradient 'W' brand mark above the heading, and tighter typography
matching the modal palette. Same purple (#873eff → #5007aa
gradient) used in the stepper modal.
* Show a clear rate-limit message on QR login when the edge limiter trips
When a merchant generates QR codes rapidly the upstream edge limiter
(Cloudflare / VIP / etc.) returns an HTML 429 page. apiFetch can't
parse that as JSON and bubbles up { code: 'invalid_json' } — the merchant
saw the raw 'The response is not a valid JSON response.' message, which
told them nothing useful.
Both useQRLoginToken (token generate) and useRevokeQRLoginAccess
(revoke) now collapse three rate-limit signals onto the same
merchant-facing copy:
- code === 'rate_limit_exceeded' (our own backend cap)
- code === 'invalid_json' (upstream edge limiter HTML response)
- data.status === 429 (any explicit 429 status)
The rendered message tells the merchant what happened ('You've requested
QR login codes too quickly') and what to do ('wait a moment and try
again'). The existing 'Try again' button on the ERROR state retries on
demand once the rate window clears.
Two new Jest tests cover the new branches; the existing
rate_limit_exceeded test was updated for the friendlier wording. 23/23
mobile-app-modal Jest tests pass.
* Lint: stylelint + phpcs cleanup on Task 5 commits
- standalone page SCSS: drop the leading/trailing space inside the
linear-gradient() call and shorten #ffffff to #fff so stylelint passes
- MobileAppQRLogin.php: align the device arg's 'required' / 'type'
arrows with its sibling 'properties' arrow
- test file: pull the trailing per-line comments out of the array literal
in test_exchange_token_sanitizes_device_fields (PostStatementComment),
expand three inline { os, model } arrays to multi-line per WordPress
ArrayDeclarationSpacing, and pad the @param doc column for $token so
it aligns with the longer array<string, string>|null type
* Lint: align $uuid with surrounding assignments
* Use POST for QR status polling
Move the token out of the query string so status checks do not leak the QR login token through server logs, browser history, or intermediary request URLs. Keep the endpoint aligned with the token-creation endpoint and cover the client request shape in tests.
* Short-circuit empty QR status tokens
Return the expired state before hashing or looking up an empty status token. The endpoint already treats missing/unknown tokens as expired, and this keeps the empty-string path explicit and covered by a REST test.
* Store consumed QR status before deleting tokens
Write the consumed status record before removing the active token transient so a status poll cannot briefly observe neither key and report an expired state after a successful exchange.
* Use the Woo logo on the standalone login page
Replace the temporary W badge with the actual Woo SVG asset so the standalone QR login page uses product branding instead of a hand-built approximation. The style now sizes the image directly instead of styling text as a logo.
* Drop the default QR login props fallback
Remove the unnecessary default object from the React component signature. Callers already pass props through React, and keeping the signature direct avoids implying that this component supports being invoked without props.
* Render consumed QR revoke errors
When revoking from the consumed state fails, the hook keeps the state as CONSUMED and only sets an error message. Pass that message into the consumed panel and cover the standalone page so the merchant can see the failure instead of a silent no-op.
* Warn when QR status polling fails
Keep transient status failures non-blocking for the QR flow, but surface the caught error through console.warn so repeated polling problems are diagnosable during development and support investigations.
* Share QR login device copy helpers
Move the duplicated signed-in headline and device subline builders into one helper module so the success step and consumed panel keep the same fallback behavior and translation strings.
* Track QR revoke attempts explicitly
Rename the revoke Tracks event to *_revoke_attempt because it fires before the async revoke call resolves. This keeps analytics from overstating successful revocations while preserving the existing user action signal.
* Let WP buttons own QR revoke styling
Remove custom color and sizing overrides from the QR renew and revoke buttons so the WordPress Button variants define their visual states consistently with the rest of wc-admin.
* Use WP palette variables for QR login UI
Replace hard-coded greys, reds, and Woo purple with the existing WP/Woo SCSS variables so the QR login modal and standalone page stay aligned with the admin design palette.
* Normalize standalone login heading typography
Drop the custom negative letter spacing so the standalone mobile-app login page follows the admin theme's typography defaults instead of introducing a bespoke text treatment.
* Fix QR login CI lint failures
Mark the QR status polling console.warn as intentional for ESLint and remove the stale PHPCS alignment spacing left after the empty-token early return split the assignment group.
* WC-Core 6: QR mobile-app login — sign-in notification email (#64457)
* Send the merchant an email when the mobile app signs in via QR
Stacks on top of Task 5 (feature/woomob-task5-qr-confirmation-revoke). After
a successful exchange (after the consumed-transient write), dispatch a
transactional email to the user that minted the token so they're aware of
the new sign-in even when not actively looking at wc-admin.
Backend (MobileAppQRLogin.php):
- New `maybe_send_sign_in_notification_email()` wrapper that consults the
new `woocommerce_qr_login_should_send_signin_email` filter for opt-out
(return false to suppress) and swallows mailer exceptions so a misconfigured
SMTP setup cannot break the exchange path.
- New `send_sign_in_notification_email()` helper uses `wp_mail()` with HTML
headers. When the WC mailer is initialized we wrap the body via
`WC()->mailer()->wrap_message()` for visual consistency with other WC
mails; otherwise we fall back to bare HTML.
- New view partial `views/mobile-app-qr-login-signin-email.php` renders the
body — minimal markup, no inline images, no JS — so it survives clipping
and dark-mode rewrites in mainstream email clients. Includes device
model/OS, app version, timestamp in the site timezone, the descriptive AP
name, and a "revoke access" link to Users → Profile → Application
Passwords.
Tests:
- `test_exchange_token_dispatches_sign_in_notification_email` — captures the
outbound mail via `pre_wp_mail` and asserts on the recipient, subject, and
the device fields in the body.
- `test_sign_in_notification_email_can_be_suppressed_via_filter` — asserts
the new filter suppresses the send entirely.
- `test_sign_in_notification_email_handles_missing_device_payload` — asserts
the fallback path doesn't render "undefined" or empty separators when an
older mobile client sends no device info.
WOOMOB-Task6
* Redesign the QR sign-in notification email with Woo branding
Replaces the bare paragraph + bullet list with an email-friendly layout
that follows the security-notification conventions merchants are used to
seeing from other services:
- Pre-header text (hidden inbox preview line) summarising 'who/where' so
the sign-in is identifiable straight from the inbox list.
- Headline + lede: 'New sign-in to your store' with the site name
emphasised in the supporting copy.
- Device card with a Woo-purple (#7f54b3) accent stripe, neutral
card background (#f6f7f7), and a clear hierarchy of model > OS >
app version > timestamp. Uses the new brand field when present
('Google Pixel 8 Pro') and falls back gracefully through model > OS >
generic for older mobile clients.
- 'Was this you?' framing with a primary 'Revoke access' CTA rendered
as a bulletproof button (table + inline-block) so it survives Outlook,
Gmail, Apple Mail, and the iOS / Android native clients.
- Footer with the descriptive Application Password name and a link to
the full Application Passwords management screen for context.
All styles are inline (style blocks get stripped by Outlook and most
webmail), the outer wrapper is a table so legacy desktop Outlook gets
the same 600px max-width as everything else, and font sizes are >= 13px
to dodge Apple Mail's small-text protection.
The body is still wrapped by WC()->mailer()->wrap_message() so the
standard WooCommerce email header/footer chrome is preserved.
* Log QR sign-in email failures via wc_get_logger()
Replace the silent unset($e) swallow with a wc_get_logger()->warning()
call sourced to mobile-app-qr-login, so mailer misconfigurations surface
in the WC log instead of being invisible. The exchange response is still
never blocked on email delivery; widening to \Throwable also catches an
\Error from a broken mailer.
* Lint: phpcs cleanup on Task 6 commits
- shrink the docblock @param shapes to plain array<string, mixed> so the
line widths fit phpcs alignment expectations (the strict shape was
duplicated across three callers and routinely overflowed)
- add declare( strict_types=1 ) to the email view partial
- rename the pre_wp_mail capture closure's first param from $return
(PHP reserved word) to $short_circuit
- annotate the suppression test's finally block with //end try
- align $capture / $suppress equals signs
- expand the inline iOS device array to multi-line per WordPress
ArrayDeclarationSpacing
* Fix PHPStan: relax filter docblock to match function signature
The function signature was tightened from `array{...}` to
`array<string, mixed>` for phpcs alignment, but the inline filter
@param above the apply_filters() call still carried the strict shape.
PHPStan flagged the mismatch because the variable passed to the filter
is the function's array<string, mixed> parameter — narrower than the
filter's declared type.
Match the function signature.
* WC-Core 7: Add number-matching approval step to QR mobile-app login (WOOMOB-2947) (#64503)
* Add number-matching approval step to QR mobile-app login
Verifies the merchant scanning the QR is the same person looking at
wc-admin. After scan the mobile app shows a 3-digit number; wc-admin
shows that number plus two server-shuffled distractors and asks the
merchant to tap the matching one. A wrong tap (or the "I don't
recognise this device" cancel link) terminates the session permanently
— defends against shoulder-surfed QR codes and leaked screenshots.
Backend introduces a single-record state machine (pending → scanned →
approved → consumed) with three new endpoints (qr-login-scan,
qr-login-approve, qr-login-session-status) and adds an exchange_grant
nonce that gates the final exchange call so an attacker who somehow
learned the token still cannot race the legitimate app to exchange
after approval. Distractor numbers are generated with random_int() and
constrained to differ by ≥100 so a partial-read leak can't fingerprint
the real one. Choice comparison uses hash_equals(). Older mobile
clients without the supports_number_matching capability flag get 426
Upgrade Required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Tighten QR login protocol: drop pre-release device-payload fallbacks
Now that the WooCommerce mobile app will ship with the number-matching
flow at the same time as this WC Core release, the compatibility layer
written to support pre-Task-7 mobile clients is dead weight.
- /qr-login-exchange: drop the optional `device` request arg. Device
info is captured at /qr-login-scan time and sourced exclusively from
the approved record. Soft-required `exchange_grant` joins the schema
so the field is documented without short-circuiting earlier checks.
- /qr-login-exchange: drop the 426 Upgrade Required path on a pending
token. With no possible legacy clients, the case collapses into the
generic 412 qr_login_not_approved.
- /qr-login-scan: device payload is now required at the schema level.
Mobile clients always have these fields available from the platform
SDK; requiring them up front keeps every downstream surface (AP
name, sign-in email, match-step device card) honest.
- format_application_password_name(): drop the literal "WooCommerce
Mobile App (QR Login)" final fallback. With device guaranteed, the
AP name always renders as "Woo Mobile · {model|os} · {date}".
- Email + React: simplify the device-line builders to skip the
no-device-at-all fallback (kept the per-field guards so an empty
os_version doesn't render " · undefined").
- Tests: dropped the three tests that covered the removed paths
(legacy AP name fallback, missing-device email body, 426 on legacy
exchange) and adapted the legacy-426 test into a "skipped scan
→ 412" assertion that still validates the state machine guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Pin the QR number-match tile order at scan time
`get_status` was calling `shuffled_candidate_numbers()` on every wc-admin
poll, which re-shuffled the triple from scratch each request. The merchant
saw the three tiles flicker into a new order every ~2.5s — confusing UX
and the kind of thing that makes a security flow look broken.
Shuffle once when the challenge is generated (in `scan_token`) and
persist the chosen order on `challenge.shuffled` in the token transient.
Status reads the stored order verbatim. The three tiles now stay put
until the merchant taps one or the 90s window elapses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Stop intermediary caches from pinning QR poll responses
The mobile app reported `{state:scanned}` indefinitely from
`/qr-login-session-status` even after the merchant tapped the matching
number on wc-admin and the server-side state flipped to `approved`. Same
symptom would surface on `/qr-login-status` for wc-admin. Root cause is
that polling endpoints are GET requests with no cache headers — any
intermediary (Cloudflare, NGINX micro-cache, OkHttp's shared cache,
browser cache) can pin the first 200 response and replay it forever.
Call `nocache_headers()` at the top of both polling endpoints so every
branch (rate-limit error, transient missing, scanned, approved, rejected,
expired, consumed) sends Cache-Control: no-cache + Pragma: no-cache +
Expires headers. Add a belt-and-suspenders `delete_transient` ahead of
the approve write, plus diagnostic logs (set_ok, ttl, readback_state) so
support can grep for "QR login approve" / "QR login session-status" in
the WC log if the symptom returns despite cache busting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Tidy up QR number-match implementation
Drop diagnostics and unused fields that grew during debugging now that
the polling cache fix resolved the underlying symptom.
- approve_token: drop the delete-then-set-then-readback dance and the
per-call info log. The Cache-Control fix in get_session_status was
the actual fix; the transient defensiveness is no longer needed.
- get_session_status: drop the per-poll debug log and the
"transient missing" info log — both fired on every tick of a normal
expired session.
- Fix STATE_* / generate_token comments that referenced a phantom
transition_state() helper.
- Drop deviceInfo from QRLoginConsumedSnapshot — only apUuid is used
now that the success step no longer renders a device line.
- Reflow the success step so "It wasn't you?" + "Revoke access" share
one row, matching the new stepper-driven layout.
* Polish wc-admin QR login surfaces
Pre-release UI cleanup so the standalone /mobile-app-login page and the
homescreen mobile-app modal render the same QR / number-match flow.
- Extract shared `.qr-direct-login*` rules into a new partial that both
stylesheets import; the standalone page was previously rendering the
shared component unstyled.
- Replace the hardcoded "W" placeholder on the standalone page with the
real `WooLogo` SVG used elsewhere in wc-admin.
- Add an `isBusy` + "Cancelling…" label on the QR number-match cancel
button, and `isBusy` on the active number tile, so taps feel
responsive while the approval request is in flight.
- Add a `.mobile-app-login-faq` rule (24px top margin, thin grey
divider, #757575 body color) so the FAQ link in the modal stops
touching the content above it; mirror the same rhythm on the
standalone page.
- Sync the standalone page's FAQ copy + URL with the modal's exact
phrase ("Any troubles signing in? Check out the FAQ.") so the two
surfaces converge on identical wording.
* Fix lint violations on Task 7 branch
Three buckets of fixes surfaced by `lint:changes:branch` after the polish
pass landed:
stylelint (the polish-pass commit introduced these):
- Add `.scss` extension to the @import partial path on both surfaces.
- Switch to double-quoted import strings to match the rest of the
monorepo's stylistic conventions.
- Drop the redundant first-line of the partial's header comment so the
block doesn't open with a `//` line that stylelint treats as empty.
phpcs (accumulated across Task 5/6/7 commits — branch-level lint runs
the full diff against trunk so they only surface now):
- Re-align the STATE_* and MAX_* constant blocks to the rule's preferred
minimum spacing.
- Hoist post-statement comments above the constant declarations.
- Replace the inline `count($distractors) < 2` loop condition with a
separate counter — Squiz disallows size-fn calls in loop conditions.
- Collapse the long generic `array{...}` shape types in the email
helper docblocks to plain `array` plus a key list in the description;
the inline shape made every following @param line need ~95 trailing
spaces to align, which the sniff rejected.
- Multi-line associative-array literals in the test file (the
WordPress.Arrays.ArrayDeclarationSpacing rule only allows single-line
for non-associative arrays).
- Rename the `$return` closure parameter on the wp_mail capture helper
— `return` is a reserved keyword.
- Add `// end try` comments to the long try/finally blocks in the email
tests so the closing-comment sniff is satisfied.
- Move `declare( strict_types=1 );` below the file docblock in the
email partial to match the convention used elsewhere in
src/Admin/API/.
* Polish QR number-match cancel control and center tiles row
Replace the single red link with a row that pairs descriptive text
("I don't recognise this device") with a secondary "Cancel login"
button + inline spinner; re-center the tile row so it lines up under
the heading on both modal and standalone surfaces. Tests updated to
query the cancel control by button role + accessible name.
* Preserve QR login state on failed exchanges
* Address security review findings on the QR number-match flow
Three hardenings from Alex Concha's review (#64302–#64591 stack):
3.1 Race condition on /qr-login-scan. The scan handler had a bare
read-check-mutate-write on the token transient: two concurrent scans
both passed the state==pending gate, both wrote a fresh challenge, and
the second writer's session_id silently won — orphaning the first
scanner's session and letting an attacker who races a screenshotted
QR end up with the canonical record.
Mirror the claim_token_for_exchange() pattern with its own option-key
prefix (SCAN_CLAIM_OPTION_PREFIX) so the scan and exchange mutexes are
independent. add_option() is a wp_options unique-constraint mutex —
database-backed, works across PHP workers regardless of object cache.
The state check stays as defense-in-depth.
3.2 exchange_grant delivered on unauthenticated polling endpoint.
/qr-login-session-status returned the grant the moment the merchant
approved, gated only by the session_id. session_id has 122 bits of
entropy so brute-forcing is infeasible, but anyone who *learned* the
session_id (mobile logs, network capture, debug output) could poll
for state and walk away with the grant.
Add a required token_hash parameter and validate via hash_equals
against the stored token_hash; on mismatch return STATE_EXPIRED
opacity (no leak of whether the session_id is real). The mobile app
already holds the plaintext token from the QR scan — passing
SHA-256(token) on every poll is essentially free for it.
3.5 Plaintext token persists in React state after scan. qrUrl held
the full token through every later state, even though it was no
longer rendered after SCANNED. Clear it on the scanned-status branch
of the polling reducer; the token stays in tokenRef for polling, so
no behavior change.
* Rename CHALLENGE_TTL to CHALLENGE_TTL_SECONDS
Make the time unit explicit at the call site so readers don't have to
chase the docblock to learn whether 90 is seconds, minutes, or ticks.
Addresses xknown's review feedback on #64503.
* Harden QR scan and session rate limits
* Left-align modal QR cancel row and FAQ paragraph
Match the "Sign into the app" step's left-anchored text flow: the
"I don't recognise this device" + Cancel login row and the
"Any troubles signing in? Check out the FAQ." line were centered
on the modal surface, which read as a layout inconsistency against
the rest of step 2's copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Share QR login token claim helper
* Quiet QR number-match countdown announcements
* Guard QR number-match approvals with an atomic claim
* Require QR scan device identity
* Surface QR number-match approval errors
* Polish QR app password fallback formatting
* Align QR device payload comments
* Fix QR number-match ReactNode typing
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* WC-Core 8: Improve QR sign-in UX when application passwords are unavailable (#64591)
* Send the merchant an email when the mobile app signs in via QR
Stacks on top of Task 5 (feature/woomob-task5-qr-confirmation-revoke). After
a successful exchange (after the consumed-transient write), dispatch a
transactional email to the user that minted the token so they're aware of
the new sign-in even when not actively looking at wc-admin.
Backend (MobileAppQRLogin.php):
- New `maybe_send_sign_in_notification_email()` wrapper that consults the
new `woocommerce_qr_login_should_send_signin_email` filter for opt-out
(return false to suppress) and swallows mailer exceptions so a misconfigured
SMTP setup cannot break the exchange path.
- New `send_sign_in_notification_email()` helper uses `wp_mail()` with HTML
headers. When the WC mailer is initialized we wrap the body via
`WC()->mailer()->wrap_message()` for visual consistency with other WC
mails; otherwise we fall back to bare HTML.
- New view partial `views/mobile-app-qr-login-signin-email.php` renders the
body — minimal markup, no inline images, no JS — so it survives clipping
and dark-mode rewrites in mainstream email clients. Includes device
model/OS, app version, timestamp in the site timezone, the descriptive AP
name, and a "revoke access" link to Users → Profile → Application
Passwords.
Tests:
- `test_exchange_token_dispatches_sign_in_notification_email` — captures the
outbound mail via `pre_wp_mail` and asserts on the recipient, subject, and
the device fields in the body.
- `test_sign_in_notification_email_can_be_suppressed_via_filter` — asserts
the new filter suppresses the send entirely.
- `test_sign_in_notification_email_handles_missing_device_payload` — asserts
the fallback path doesn't render "undefined" or empty separators when an
older mobile client sends no device info.
WOOMOB-Task6
* Redesign the QR sign-in notification email with Woo branding
Replaces the bare paragraph + bullet list with an email-friendly layout
that follows the security-notification conventions merchants are used to
seeing from other services:
- Pre-header text (hidden inbox preview line) summarising 'who/where' so
the sign-in is identifiable straight from the inbox list.
- Headline + lede: 'New sign-in to your store' with the site name
emphasised in the supporting copy.
- Device card with a Woo-purple (#7f54b3) accent stripe, neutral
card background (#f6f7f7), and a clear hierarchy of model > OS >
app version > timestamp. Uses the new brand field when present
('Google Pixel 8 Pro') and falls back gracefully through model > OS >
generic for older mobile clients.
- 'Was this you?' framing with a primary 'Revoke access' CTA rendered
as a bulletproof button (table + inline-block) so it survives Outlook,
Gmail, Apple Mail, and the iOS / Android native clients.
- Footer with the descriptive Application Password name and a link to
the full Application Passwords management screen for context.
All styles are inline (style blocks get stripped by Outlook and most
webmail), the outer wrapper is a table so legacy desktop Outlook gets
the same 600px max-width as everything else, and font sizes are >= 13px
to dodge Apple Mail's small-text protection.
The body is still wrapped by WC()->mailer()->wrap_message() so the
standard WooCommerce email header/footer chrome is preserved.
* Log QR sign-in email failures via wc_get_logger()
Replace the silent unset($e) swallow with a wc_get_logger()->warning()
call sourced to mobile-app-qr-login, so mailer misconfigurations surface
in the WC log instead of being invisible. The exchange response is still
never blocked on email delivery; widening to \Throwable also catches an
\Error from a broken mailer.
* Lint: phpcs cleanup on Task 6 commits
- shrink the docblock @param shapes to plain array<string, mixed> so the
line widths fit phpcs alignment expectations (the strict shape was
duplicated across three callers and routinely overflowed)
- add declare( strict_types=1 ) to the email view partial
- rename the pre_wp_mail capture closure's first param from $return
(PHP reserved word) to $short_circuit
- annotate the suppression test's finally block with //end try
- align $capture / $suppress equals signs
- expand the inline iOS device array to multi-line per WordPress
ArrayDeclarationSpacing
* Fix PHPStan: relax filter docblock to match function signature
The function signature was tightened from `array{...}` to
`array<string, mixed>` for phpcs alignment, but the inline filter
@param above the apply_filters() call still carried the strict shape.
PHPStan flagged the mismatch because the variable passed to the filter
is the function's array<string, mixed> parameter — narrower than the
filter's declared type.
Match the function signature.
* Add number-matching approval step to QR mobile-app login
Verifies the merchant scanning the QR is the same person looking at
wc-admin. After scan the mobile app shows a 3-digit number; wc-admin
shows that number plus two server-shuffled distractors and asks the
merchant to tap the matching one. A wrong tap (or the "I don't
recognise this device" cancel link) terminates the session permanently
— defends against shoulder-surfed QR codes and leaked screenshots.
Backend introduces a single-record state machine (pending → scanned →
approved → consumed) with three new endpoints (qr-login-scan,
qr-login-approve, qr-login-session-status) and adds an exchange_grant
nonce that gates the final exchange call so an attacker who somehow
learned the token still cannot race the legitimate app to exchange
after approval. Distractor numbers are generated with random_int() and
constrained to differ by ≥100 so a partial-read leak can't fingerprint
the real one. Choice comparison uses hash_equals(). Older mobile
clients without the supports_number_matching capability flag get 426
Upgrade Required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Tighten QR login protocol: drop pre-release device-payload fallbacks
Now that the WooCommerce mobile app will ship with the number-matching
flow at the same time as this WC Core release, the compatibility layer
written to support pre-Task-7 mobile clients is dead weight.
- /qr-login-exchange: drop the optional `device` request arg. Device
info is captured at /qr-login-scan time and sourced exclusively from
the approved record. Soft-required `exchange_grant` joins the schema
so the field is documented without short-circuiting earlier checks.
- /qr-login-exchange: drop the 426 Upgrade Required path on a pending
token. With no possible legacy clients, the case collapses into the
generic 412 qr_login_not_approved.
- /qr-login-scan: device payload is now required at the schema level.
Mobile clients always have these fields available from the platform
SDK; requiring them up front keeps every downstream surface (AP
name, sign-in email, match-step device card) honest.
- format_application_password_name(): drop the literal "WooCommerce
Mobile App (QR Login)" final fallback. With device guaranteed, the
AP name always renders as "Woo Mobile · {model|os} · {date}".
- Email + React: simplify the device-line builders to skip the
no-device-at-all fallback (kept the per-field guards so an empty
os_version doesn't render " · undefined").
- Tests: dropped the three tests that covered the removed paths
(legacy AP name fallback, missing-device email body, 426 on legacy
exchange) and adapted the legacy-426 test into a "skipped scan
→ 412" assertion that still validates the state machine guard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Pin the QR number-match tile order at scan time
`get_status` was calling `shuffled_candidate_numbers()` on every wc-admin
poll, which re-shuffled the triple from scratch each request. The merchant
saw the three tiles flicker into a new order every ~2.5s — confusing UX
and the kind of thing that makes a security flow look broken.
Shuffle once when the challenge is generated (in `scan_token`) and
persist the chosen order on `challenge.shuffled` in the token transient.
Status reads the stored order verbatim. The three tiles now stay put
until the merchant taps one or the 90s window elapses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Stop intermediary caches from pinning QR poll responses
The mobile app reported `{state:scanned}` indefinitely from
`/qr-login-session-status` even after the merchant tapped the matching
number on wc-admin and the server-side state flipped to `approved`. Same
symptom would surface on `/qr-login-status` for wc-admin. Root cause is
that polling endpoints are GET requests with no cache headers — any
intermediary (Cloudflare, NGINX micro-cache, OkHttp's shared cache,
browser cache) can pin the first 200 response and replay it forever.
Call `nocache_headers()` at the top of both polling endpoints so every
branch (rate-limit error, transient missing, scanned, approved, rejected,
expired, consumed) sends Cache-Control: no-cache + Pragma: no-cache +
Expires headers. Add a belt-and-suspenders `delete_transient` ahead of
the approve write, plus diagnostic logs (set_ok, ttl, readback_state) so
support can grep for "QR login approve" / "QR login session-status" in
the WC log if the symptom returns despite cache busting.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Tidy up QR number-match implementation
Drop diagnostics and unused fields that grew during debugging now that
the polling cache fix resolved the underlying symptom.
- approve_token: drop the delete-then-set-then-readback dance and the
per-call info log. The Cache-Control fix in get_session_status was
the actual fix; the transient defensiveness is no longer needed.
- get_session_status: drop the per-poll debug log and the
"transient missing" info log — both fired on every tick of a normal
expired session.
- Fix STATE_* / generate_token comments that referenced a phantom
transition_state() helper.
- Drop deviceInfo from QRLoginConsumedSnapshot — only apUuid is used
now that the success step no longer renders a device line.
- Reflow the success step so "It wasn't you?" + "Revoke access" share
one row, matching the new stepper-driven layout.
* Polish wc-admin QR login surfaces
Pre-release UI cleanup so the standalone /mobile-app-login page and the
homescreen mobile-app modal render the same QR / number-match flow.
- Extract shared `.qr-direct-login*` rules into a new partial that both
stylesheets import; the standalone page was previously rendering the
shared component unstyled.
- Replace the hardcoded "W" placeholder on the standalone page with the
real `WooLogo` SVG used elsewhere in wc-admin.
- Add an `isBusy` + "Cancelling…" label on the QR number-match cancel
button, and `isBusy` on the active number tile, so taps feel
responsive while the approval request is in flight.
- Add a `.mobile-app-login-faq` rule (24px top margin, thin grey
divider, #757575 body color) so the FAQ link in the modal stops
touching the content above it; mirror the same rhythm on the
standalone page.
- Sync the standalone page's FAQ copy + URL with the modal's exact
phrase ("Any troubles signing in? Check out the FAQ.") so the two
surfaces converge on identical wording.
* Fix lint violations on Task 7 branch
Three buckets of fixes surfaced by `lint:changes:branch` after the polish
pass landed:
stylelint (the polish-pass commit introduced these):
- Add `.scss` extension to the @import partial path on both surfaces.
- Switch to double-quoted import strings to match the rest of the
monorepo's stylistic conventions.
- Drop the redundant first-line of the partial's header comment so the
block doesn't open with a `//` line that stylelint treats as empty.
phpcs (accumulated across Task 5/6/7 commits — branch-level lint runs
the full diff against trunk so they only surface now):
- Re-align the STATE_* and MAX_* constant blocks to the rule's preferred
minimum spacing.
- Hoist post-statement comments above the constant declarations.
- Replace the inline `count($distractors) < 2` loop condition with a
separate counter — Squiz disallows size-fn calls in loop conditions.
- Collapse the long generic `array{...}` shape types in the email
helper docblocks to plain `array` plus a key list in the description;
the inline shape made every following @param line need ~95 trailing
spaces to align, which the sniff rejected.
- Multi-line associative-array literals in the test file (the
WordPress.Arrays.ArrayDeclarationSpacing rule only allows single-line
for non-associative arrays).
- Rename the `$return` closure parameter on the wp_mail capture helper
— `return` is a reserved keyword.
- Add `// end try` comments to the long try/finally blocks in the email
tests so the closing-comment sniff is satisfied.
- Move `declare( strict_types=1 );` below the file docblock in the
email partial to match the convention used elsewhere in
src/Admin/API/.
* Polish QR number-match cancel control and center tiles row
Replace the single red link with a row that pairs descriptive text
("I don't recognise this device") with a secondary "Cancel login"
button + inline spinner; re-center the tile row so it lines up under
the heading on both modal and standalone surfaces. Tests updated to
query the cancel control by button role + accessible name.
* Preserve QR login state on failed exchanges
* Address security review findings on the QR number-match flow
Three hardenings from Alex Concha's review (#64302–#64591 stack):
3.1 Race condition on /qr-login-scan. The scan handler had a bare
read-check-mutate-write on the token transient: two concurrent scans
both passed the state==pending gate, both wrote a fresh challenge, and
the second writer's session_id silently won — orphaning the first
scanner's session and letting an attacker who races a screenshotted
QR end up with the canonical record.
Mirror the claim_token_for_exchange() pattern with its own option-key
prefix (SCAN_CLAIM_OPTION_PREFIX) so the scan and exchange mutexes are
independent. add_option() is a wp_options unique-constraint mutex —
database-backed, works across PHP workers regardless of object cache.
The state check stays as defense-in-depth.
3.2 exchange_grant delivered on unauthenticated polling endpoint.
/qr-login-session-status returned the grant the moment the merchant
approved, gated only by the session_id. session_id has 122 bits of
entropy so brute-forcing is infeasible, but anyone who *learned* the
session_id (mobile logs, network capture, debug output) could poll
for state and walk away with the grant.
Add a required token_hash parameter and validate via hash_equals
against the stored token_hash; on mismatch return STATE_EXPIRED
opacity (no leak of whether the session_id is real). The mobile app
already holds the plaintext token from the QR scan — passing
SHA-256(token) on every poll is essentially free for it.
3.5 Plaintext token persists in React state after scan. qrUrl held
the full token through every later state, even though it was no
longer rendered after SCANNED. Clear it on the scanned-status branch
of the polling reducer; the token stays in tokenRef for polling, so
no behavior change.
* Rename CHALLENGE_TTL to CHALLENGE_TTL_SECONDS
Make the time unit explicit at the call site so readers don't have to
chase the docblock to learn whether 90 is seconds, minutes, or ticks.
Addresses xknown's review feedback on #64503.
* Harden QR scan and session rate limits
* Left-align modal QR cancel row and FAQ paragraph
Match the "Sign into the app" step's left-anchored text flow: the
"I don't recognise this device" + Cancel login row and the
"Any troubles signing in? Check out the FAQ." line were centered
on the modal surface, which read as a layout inconsistency against
the rest of step 2's copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Share QR login token claim helper
* Quiet QR number-match countdown announcements
* Guard QR number-match approvals with an atomic claim
* Require QR scan device identity
* Surface QR number-match approval errors
* Polish QR app password fallback formatting
* Align QR device payload comments
* Fix QR number-match ReactNode typing
* Improve QR sign-in UX when application passwords are unavailable
Three improvements brainstormed off the AP-disabled error case in #64319:
1. Up-front detection. New REST endpoint
/wc-admin/mobile-app/qr-login-availability returns
{ available, reason } so wc-admin can render a permanently-disabled QR
card instead of mounting <QRDirectLoginCode />, spinning, calling
/qr-login-token, and only then showing a generic error. The reason
code distinguishes https_required / application_passwords_unsupported
/ application_passwords_disabled_by_filter so the merchant-facing
message can fit the cause.
2. Smarter ERROR-state action. When the failure is structural
(errorCode === 'application_passwords_unavailable') the primary
button swaps from a Try again that can't possibly succeed to a
one-click shortcut to wp-admin's Application Passwords settings.
3. Why am I seeing this? expander on the disabled card. Native <details>
block listing the typical causes (security plugin, custom filter,
missing HTTPS) so the merchant-facing copy stays short while the
diagnostic context is one click away.
Adds: useQRLoginAvailability hook, QRLoginUnavailableCard component,
matching SCSS, 5 new PHPUnit tests for the endpoint, 5 new Jest tests
for the hook. Existing MobileAppLoginStepper tests mock the new hook
to short-circuit the up-front probe.
* Tighten unavailable-card layout: button inside disclosure, plain summary
Two readability passes after seeing the rendered card:
- Move the "Open Application Passwords settings" button inside the
<details> expander, after the bullet list. The CTA is most useful
*after* the merchant has read the diagnostic context, and folding it
under the disclosure keeps the default state quiet — only the warning
notice + collapsed summary show by default.
- Width the button to its content (`width: auto; align-self: flex-start`)
rather than stretching across the column. It reads as a contextual
inline action, not a primary full-width CTA.
- Recolour the <summary> from link-blue (#007cba) to regular text
(#1d2327, font-weight 500). It's a disclosure trigger, not a link —
the native arrow marker is enough affordance.
* Fix QR availability formatting
* Mock useQRLoginAvailability in mobile-app-login Jest tests
The standalone-page tests render the real <QRDirectLoginCode /> through
the page shell. After the up-front availability probe was added in this
PR (#64591), the component now consults /qr-login-availability before
rendering anything else, which leaves these tests stuck on the
availability spinner because the probe never resolves in the Jest env.
Mock useQRLoginAvailability to short-circuit the probe and return the
'available' branch — same pattern already used in the
MobileAppLoginStepper.test.tsx suite. The probe itself has its own
dedicated suite (useQRLoginAvailability.test.ts).
* Prefix AP-disabled QR login UI
Use the same WooCommerce-prefixed QR login class namespace for the unavailable card and availability loading state so the final stack does not reintroduce generic qr-direct-login selectors after the base PR cleanup.
Also switch the unavailable-card inline docs link to createInterpolateElement so new QR login copy follows the WordPress interpolation API used by the restacked base branch.
* Destructure errorCode from useQRLoginToken in QRDirectLoginCode
The AP-disabled UX commit added a structural-AP-failure branch to the
ERROR-state action that checks `errorCode === 'application_passwords_unavailable'`,
but left `errorCode` off the destructure list pulled from
`useQRLoginToken()`. That caused a TypeScript error
(`Cannot find name 'errorCode'`) at QRDirectLoginCode.tsx:153 which
broke `build:project:typescript-check` and, with it, the Build Live
Branch, asset-size, e2e, and admin-library Jest jobs (the last surfaced
the same fault at runtime as `ReferenceError: errorCode is not defined`
when rendering the ERROR state).
`useQRLoginToken` already exposes `errorCode` (state hook on line 121,
returned on line 601), so this is a one-line fix to wire the consumer
up to the existing producer.
* Polish AP-disabled QR-login card: soften copy, drop settings shortcut, left-align
Three iterative refinements to the AP-disabled state shipped in #64591:
- Rephrase the headline from "QR sign-in is unavailable because application
passwords are disabled on this site" to "Mobile login is unavailable if
application passwords are disabled on your site" — frames the limitation
around the higher-level capability and softens the tense.
- Remove the "Open Application Passwords settings" CTA from both surfaces
where it appeared (the unavailable card's disclosure and the structural
ERROR-state) — it was noisier than helpful inside the diagnostic flow.
The structural `application_passwords_unavailable` ERROR-state now shows
no retry button at all, since retrying cannot change the AP configuration.
- Left-align the Notice text so it lines up with the rest of the column;
the WP `Notice` component centers its body by default.
Modal flow (`homescreen/mobile-app-modal`) and standalone wc-admin page
(`/mobile-app-login`) share `QRLoginUnavailableCard` via `QRDirectLoginCode`,
so a single edit covers both surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix mobile QR login review findings (#65126)
* Fix mobile QR login review findings
* Update QR login changelog wording
* Fix QR login approved token expiry
* Revert changelog copy edits to original wording
Jirka flagged the changelog rewording as out of scope for this review-fix
PR. Restore the four woomob-* entries to the wording already present on the
feature branch; this PR should only carry the follow-up code fixes.
* Log stale-claim cleanup DB errors instead of swallowing them
Per review feedback: delete_claim_if_value_matches cast the $wpdb->query
result to int before inspecting it, collapsing a DB error (false) and a
no-row-matched (0) into the same value, so a failing mutex cleanup was
invisible. Capture the raw result and log $wpdb->last_error via
wc_get_logger() when the DELETE errors. Return semantics are unchanged:
false on either error or no match.
* Add test for approve returning 410 on an expired token
Covers the approve_token expiry branch raised in review: a token that lapses
between scan and tap must return 410 qr_login_expired and must never mint an
exchange_grant, even when the tapped number is correct. This path is wired to
the wc-admin client's terminal "login denied" screen, so it warrants coverage.
* Fix mobile QR login CI failures
* Address QR login review feedback
* Fix QR login token hash expectation
* Show device details alongside app version on QR login success step
The modal flow's final "signed in successfully" step did not show which device signed in. The standalone flow already renders the device details + app version on one line via QRLoginConsumedPanel (buildQRLoginDeviceSubline); the modal's success step was missing it because only apUuid was bubbled up through the consumed snapshot.
Carry deviceInfo through QRLoginConsumedSnapshot and render the same single-line subline (reusing buildQRLoginDeviceSubline and the existing qr-login-success-step__device-details style) on QRLoginSuccessStep.
* Style QR success step device-details line as a single line
The device-details paragraph added to QRLoginSuccessStep referenced a
class with no stylesheet rule, so it rendered with default UA margins
and font size. Add a rule mirroring the sibling __description style
(13px, gray-700, single bottom margin) so it shows as one clean line,
matching the standalone QRLoginConsumedPanel subline.
* Show device model on QR login success step
Step 3 (success step) was rendering buildQRLoginDeviceSubline, which
omits the device model (it only shows OS + app version) because the
standalone QRLoginConsumedPanel already names the model in its headline.
The modal flow has no such headline, so the model — the most
recognizable field for spotting a wrong-device scan — was lost.
Promote the number-match step's model-inclusive line builder into the
shared qrLoginDeviceCopy as buildQRLoginDeviceLine (model · OS version ·
App version) and use it from both step 2 and step 3, so the success step
shows the same device line as the number-match step. The standalone
panel keeps using buildQRLoginDeviceSubline (model-less) to avoid
duplicating the model it already shows in its headline.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/woomob-2764-qr-login b/plugins/woocommerce/changelog/woomob-2764-qr-login
new file mode 100644
index 00000000000..e4c9455bb5c
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2764-qr-login
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add QR code login for the WooCommerce mobile app so eligible store managers can sign in directly without a linked WordPress.com account.
diff --git a/plugins/woocommerce/changelog/woomob-2765-modal-polish b/plugins/woocommerce/changelog/woomob-2765-modal-polish
new file mode 100644
index 00000000000..379f9cd7d5b
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2765-modal-polish
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Drop the outdated "app version needs to be 15.7" helper text from the mobile-app modal QR view and surface the underlying Jetpack error in the magic-link failure notice so support and merchants can diagnose the problem instead of hitting a generic retry message.
diff --git a/plugins/woocommerce/changelog/woomob-2765-show-qr-direct-login-for-all-admins b/plugins/woocommerce/changelog/woomob-2765-show-qr-direct-login-for-all-admins
new file mode 100644
index 00000000000..edef5fe9ef7
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2765-show-qr-direct-login-for-all-admins
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Always show the QR direct login as the primary path in the mobile app modal, with the WordPress.com magic link available as a secondary CTA when the user has a linked WordPress.com account.
diff --git a/plugins/woocommerce/changelog/woomob-2766-application-passwords-disabled-copy b/plugins/woocommerce/changelog/woomob-2766-application-passwords-disabled-copy
new file mode 100644
index 00000000000..c2240082394
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2766-application-passwords-disabled-copy
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Improve the error shown on the QR mobile-app login screen when application passwords are disabled: drop the "ask a site administrator" copy (the merchant hitting this flow is already an admin or shop manager) and replace it with an inline link to the WordPress application-passwords documentation so they can figure out which constant or plugin disabled them.
diff --git a/plugins/woocommerce/changelog/woomob-2766-update-useqrlogintoken-error-handling b/plugins/woocommerce/changelog/woomob-2766-update-useqrlogintoken-error-handling
new file mode 100644
index 00000000000..836cf09c94d
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2766-update-useqrlogintoken-error-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Update the useQRLoginToken admin hook to handle the capability-based permission errors surfaced by the mobile-app QR login endpoint.
diff --git a/plugins/woocommerce/changelog/woomob-2767-standalone-mobile-app-login-page b/plugins/woocommerce/changelog/woomob-2767-standalone-mobile-app-login-page
new file mode 100644
index 00000000000..969b64f63f6
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2767-standalone-mobile-app-login-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Add a new standalone wc-admin page at /mobile-app-login for merchants who already have the Woo mobile app installed and want to sign in via QR code scan.
diff --git a/plugins/woocommerce/changelog/woomob-2947-cancel-button-polish b/plugins/woocommerce/changelog/woomob-2947-cancel-button-polish
new file mode 100644
index 00000000000..64c9a713f36
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2947-cancel-button-polish
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Polish the QR number-match cancel control: replace the single red link with a row that pairs descriptive text ("I don't recognise this device") with a secondary "Cancel login" button, and re-center the tile row so it visually aligns with the heading on both modal and standalone surfaces.
diff --git a/plugins/woocommerce/changelog/woomob-2947-modal-text-alignment b/plugins/woocommerce/changelog/woomob-2947-modal-text-alignment
new file mode 100644
index 00000000000..f1dc78fd932
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2947-modal-text-alignment
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Left-align the QR number-match cancel row and the modal FAQ paragraph so they match the rest of the "Sign into the app" step content.
diff --git a/plugins/woocommerce/changelog/woomob-2947-security-review-hardening b/plugins/woocommerce/changelog/woomob-2947-security-review-hardening
new file mode 100644
index 00000000000..edbc83d3ae8
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-2947-security-review-hardening
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Address security review findings on the Task 7 QR login flow: add a database-backed atomic claim to /qr-login-scan to prevent two concurrent scans from both writing a fresh challenge (the loser's session_id was silently orphaned), bind /qr-login-session-status grant delivery to proof of token knowledge via a required token_hash parameter, and clear the plaintext token from React state once the scan is in.
diff --git a/plugins/woocommerce/changelog/woomob-qr-ap-disabled-ux-followup b/plugins/woocommerce/changelog/woomob-qr-ap-disabled-ux-followup
new file mode 100644
index 00000000000..b0113518df4
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-qr-ap-disabled-ux-followup
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Better UX when WooCommerce mobile-app sign-in is unavailable because application passwords are disabled. wc-admin now probes a new `/qr-login-availability` REST endpoint up-front and renders a permanently-disabled card (with a "Why am I seeing this?" expander listing typical causes) instead of optimistically mounting the QR component, spinning, and only then showing a generic error. The structural `application_passwords_unavailable` ERROR-state suppresses the "Try again" retry button since retrying cannot change the site's AP configuration.
diff --git a/plugins/woocommerce/changelog/woomob-task5-qr-login-confirmation-revoke b/plugins/woocommerce/changelog/woomob-task5-qr-login-confirmation-revoke
new file mode 100644
index 00000000000..093dd05fe05
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-task5-qr-login-confirmation-revoke
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an in-app confirmation flow to the mobile-app QR login: wc-admin polls a new status endpoint while the QR is on screen, transitions to a "Signed in successfully on {device}" panel after the mobile app exchanges the token, exposes an "It wasn't you? Revoke access" button, and adds a persistent "Renew code" button alongside the countdown. The Application Password is now named with the device model and date so the merchant can identify it in Users → Profile → Application Passwords.
diff --git a/plugins/woocommerce/changelog/woomob-task5-qr-login-success-step b/plugins/woocommerce/changelog/woomob-task5-qr-login-success-step
new file mode 100644
index 00000000000..07c0969e990
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-task5-qr-login-success-step
@@ -0,0 +1,4 @@
+Significance: minor
+Type: tweak
+
+Promote the post-sign-in confirmation to a third step in the mobile-app modal flow. Once the QR code is exchanged the modal advances past the magic-link section and FAQ to a dedicated "Signed in successfully" step with a larger heading, the device summary, and a primary "Revoke access" button that opens a confirmation dialog before deleting the Application Password.
diff --git a/plugins/woocommerce/changelog/woomob-task6-email-failure-logging b/plugins/woocommerce/changelog/woomob-task6-email-failure-logging
new file mode 100644
index 00000000000..0970291c204
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-task6-email-failure-logging
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Log mailer failures from the QR sign-in notification email via `wc_get_logger()` instead of swallowing the exception silently, so a misconfigured mailer is observable rather than invisible. The exchange response is still never blocked by mail delivery.
diff --git a/plugins/woocommerce/changelog/woomob-task6-qr-login-signin-email b/plugins/woocommerce/changelog/woomob-task6-qr-login-signin-email
new file mode 100644
index 00000000000..dc070909e9c
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-task6-qr-login-signin-email
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Send the merchant a transactional email after a successful QR mobile-app sign-in summarizing the device, OS, app version, and timestamp, with a "revoke access" link. Wrapped in a `woocommerce_qr_login_should_send_signin_email` filter so site owners can suppress the send.
diff --git a/plugins/woocommerce/changelog/woomob-task7-qr-login-number-matching b/plugins/woocommerce/changelog/woomob-task7-qr-login-number-matching
new file mode 100644
index 00000000000..20155fe57a8
--- /dev/null
+++ b/plugins/woocommerce/changelog/woomob-task7-qr-login-number-matching
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a number-matching approval step to the mobile-app QR login: after the merchant scans the QR, the mobile app shows a 3-digit number, and wc-admin asks the merchant to tap the matching value from a server-shuffled triple. A wrong tap (or the explicit "I don't recognise this device" cancel link) terminates the session permanently — defending against shoulder-surfed QR codes and leaked screenshots. Older mobile clients without the new capability flag receive 426 Upgrade Required so they can prompt for an app update.
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginInfo.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginInfo.tsx
index c4a5c0d8829..312e51b823e 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginInfo.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginInfo.tsx
@@ -18,12 +18,6 @@ export const MobileAppLoginInfo = ( {
{ loginUrl && (
<div>
<QRCodeSVG value={ loginUrl } size={ 140 } />
- <p>
- { __(
- 'The app version needs to be 15.7 or above to sign in with this link.',
- 'woocommerce'
- ) }
- </p>
</div>
) }
<div>
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginStepper.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginStepper.tsx
index a4765b20016..a2069f494ca 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginStepper.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/MobileAppLoginStepper.tsx
@@ -3,31 +3,48 @@
*/
import React, { useState, useEffect } from '@wordpress/element';
import { Button } from '@wordpress/components';
-import { sprintf, __ } from '@wordpress/i18n';
-import { Stepper, StepperProps } from '@woocommerce/components';
+import { __ } from '@wordpress/i18n';
+import { Stepper, StepperProps, Link } from '@woocommerce/components';
+import interpolateComponents from '@automattic/interpolate-components';
+import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
-import { SendMagicLinkButton, SendMagicLinkStates } from './';
-import { getAdminSetting } from '~/utils/admin-settings';
+import { SendMagicLinkStates } from './';
import { MobileAppInstallationInfo } from '../components/MobileAppInstallationInfo';
-import { MobileAppLoginInfo } from '../components/MobileAppLoginInfo';
+import { QRDirectLoginCode } from '../components/QRDirectLoginCode';
+import type { QRLoginConsumedSnapshot } from '../components/QRDirectLoginCode';
+import { SendMagicLinkButton } from '../components/SendMagicLinkButton';
+import { QRLoginSuccessStep } from '../components/QRLoginSuccessStep';
export const MobileAppLoginStepper = ( {
step,
isJetpackPluginInstalled,
wordpressAccountEmailAddress,
+ signInResult,
completeInstallationStepHandler,
sendMagicLinkHandler,
sendMagicLinkStatus,
+ onSignedIn,
}: {
- step: 'first' | 'second';
+ step: 'first' | 'second' | 'third';
isJetpackPluginInstalled: boolean;
wordpressAccountEmailAddress: string | undefined;
+ /**
+ * Snapshot of the consumed QR login. Provided when the parent has
+ * advanced to step `'third'`; rendered by `QRLoginSuccessStep`.
+ */
+ signInResult: QRLoginConsumedSnapshot | null;
completeInstallationStepHandler: () => void;
sendMagicLinkHandler: () => void;
sendMagicLinkStatus: SendMagicLinkStates;
+ /**
+ * Fires once the QR component reports a successful exchange. The parent
+ * uses this to record `signInResult` and advance the stepper to the
+ * third step.
+ */
+ onSignedIn: ( snapshot: QRLoginConsumedSnapshot ) => void;
} ) => {
const [ stepsToDisplay, setStepsToDisplay ] = useState<
StepperProps[ 'steps' ] | undefined
@@ -65,79 +82,122 @@ export const MobileAppLoginStepper = ( {
description: '',
content: <></>,
},
+ {
+ key: 'third',
+ label: __( 'Signed in', 'woocommerce' ),
+ description: '',
+ content: <></>,
+ },
] );
} else if ( step === 'second' ) {
- if (
+ const hasLinkedWordPressAccount =
isJetpackPluginInstalled &&
- wordpressAccountEmailAddress !== undefined
- ) {
- setStepsToDisplay( [
- {
- key: 'first',
- label: __( 'App installed', 'woocommerce' ),
- description: '',
- content: <></>,
- },
- {
- key: 'second',
- label: 'Sign into the app',
- description: sprintf(
- /* translators: Reflecting to the user that the magic link has been sent to their WordPress account email address */
- __(
- 'We’ll send a magic link to %s. Open it on your smartphone or tablet to sign into your store instantly.',
- 'woocommerce'
- ),
- wordpressAccountEmailAddress
- ),
- content: (
- <SendMagicLinkButton
- isFetching={
- sendMagicLinkStatus ===
- SendMagicLinkStates.FETCHING
- }
- onClickHandler={ sendMagicLinkHandler }
+ wordpressAccountEmailAddress !== undefined;
+ setStepsToDisplay( [
+ {
+ key: 'first',
+ label: __( 'App installed', 'woocommerce' ),
+ description: '',
+ content: <></>,
+ },
+ {
+ key: 'second',
+ label: __( 'Sign into the app', 'woocommerce' ),
+ description: __(
+ 'Scan the QR code below with your phone to sign in instantly — no password needed.',
+ 'woocommerce'
+ ),
+ content: (
+ <>
+ <QRDirectLoginCode
+ onConsumed={ onSignedIn }
+ suppressInlinePanels
/>
- ),
- },
- ] );
- } else {
- const siteUrl: string = getAdminSetting( 'siteUrl' );
- const username = getAdminSetting( 'currentUserData' ).username;
- const loginUrl = `woocommerce://app-login?siteUrl=${ encodeURIComponent(
- siteUrl
- ) }&username=${ encodeURIComponent( username ) }`;
- const description = loginUrl
- ? __(
- 'Scan the QR code below and enter the wp-admin password in the app.',
- 'woocommerce'
- )
- : __(
- 'Follow the instructions in the app to sign in.',
- 'woocommerce'
- );
- setStepsToDisplay( [
- {
- key: 'first',
- label: __( 'App installed', 'woocommerce' ),
- description: '',
- content: <></>,
- },
- {
- key: 'second',
- label: 'Sign into the app',
- description,
- content: <MobileAppLoginInfo loginUrl={ loginUrl } />,
- },
- ] );
- }
+ { hasLinkedWordPressAccount && (
+ <div className="mobile-app-login-magic-link-secondary">
+ <p className="mobile-app-login-magic-link-secondary__label">
+ { __(
+ 'Or get a WordPress.com sign-in link by email:',
+ 'woocommerce'
+ ) }
+ </p>
+ <SendMagicLinkButton
+ onClickHandler={ sendMagicLinkHandler }
+ isFetching={
+ sendMagicLinkStatus ===
+ SendMagicLinkStates.FETCHING
+ }
+ />
+ </div>
+ ) }
+ <div className="mobile-app-login-faq">
+ { interpolateComponents( {
+ mixedString: __(
+ 'Any troubles signing in? Check out the {{link}}FAQ{{/link}}.',
+ 'woocommerce'
+ ),
+ components: {
+ link: (
+ <Link
+ href="https://woocommerce.com/document/android-ios-apps-login-help-faq/"
+ target="_blank"
+ type="external"
+ onClick={ () => {
+ recordEvent(
+ 'onboarding_app_login_faq_click'
+ );
+ } }
+ />
+ ),
+ },
+ } ) }
+ </div>
+ </>
+ ),
+ },
+ {
+ key: 'third',
+ label: __( 'Signed in', 'woocommerce' ),
+ description: '',
+ content: <></>,
+ },
+ ] );
+ } else if ( step === 'third' ) {
+ setStepsToDisplay( [
+ {
+ key: 'first',
+ label: __( 'App installed', 'woocommerce' ),
+ description: '',
+ content: <></>,
+ },
+ {
+ key: 'second',
+ label: __( 'Sign-in complete', 'woocommerce' ),
+ description: '',
+ content: <></>,
+ },
+ {
+ key: 'third',
+ label: __( 'Signed in successfully', 'woocommerce' ),
+ description: '',
+ content: (
+ <QRLoginSuccessStep
+ apUuid={ signInResult?.apUuid ?? null }
+ deviceInfo={ signInResult?.deviceInfo ?? null }
+ />
+ ),
+ },
+ ] );
}
}, [
step,
isJetpackPluginInstalled,
wordpressAccountEmailAddress,
+ signInResult,
completeInstallationStepHandler,
sendMagicLinkHandler,
sendMagicLinkStatus,
+ onSignedIn,
] );
return (
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRDirectLoginCode.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRDirectLoginCode.tsx
new file mode 100644
index 00000000000..b38d7b185ef
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRDirectLoginCode.tsx
@@ -0,0 +1,365 @@
+/**
+ * External dependencies
+ */
+import { QRCodeSVG } from 'qrcode.react';
+import React, { useEffect, useRef } from '@wordpress/element';
+import { Button, Spinner } from '@wordpress/components';
+import { sprintf, __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { useQRLoginToken, QRLoginTokenStates } from './useQRLoginToken';
+import type { QRLoginDeviceInfo } from './useQRLoginToken';
+import { useQRLoginAvailability } from './useQRLoginAvailability';
+import { QRLoginConsumedPanel } from './QRLoginConsumedPanel';
+import { QRLoginRevokedPanel } from './QRLoginRevokedPanel';
+import { QRLoginNumberMatchStep } from './QRLoginNumberMatchStep';
+import { QRLoginUnavailableCard } from './QRLoginUnavailableCard';
+
+/**
+ * Snapshot the parent receives via `onConsumed`. The success step uses its
+ * own `useRevokeQRLoginAccess` hook (so it stays self-contained after the QR
+ * component is unmounted), so it needs the AP UUID to drive the revoke CTA.
+ * It also carries the `deviceInfo` so the success step can show which device
+ * signed in — mirroring the standalone `QRLoginConsumedPanel` — since the QR
+ * component is unmounted by the time the success step renders.
+ */
+export type QRLoginConsumedSnapshot = {
+ apUuid: string | null;
+ deviceInfo: QRLoginDeviceInfo | null;
+};
+
+type QRDirectLoginCodeProps = {
+ /**
+ * Optional callback invoked when the merchant clicks "Done" on the
+ * consumed/revoked panels. Surfaces are free to no-op (e.g. the standalone
+ * page) or close themselves (e.g. the homescreen modal).
+ */
+ onDone?: () => void;
+ /**
+ * Fires once the internal token state transitions to CONSUMED. Used by
+ * the homescreen modal stepper to advance to its third step. Standalone
+ * surfaces can leave this prop unset and the inline `QRLoginConsumedPanel`
+ * keeps its existing behavior.
+ */
+ onConsumed?: ( snapshot: QRLoginConsumedSnapshot ) => void;
+ /**
+ * When `true`, the component returns `null` for the CONSUMED and REVOKED
+ * states so the parent surface can render its own confirmation UI. Used
+ * by the stepper, which renders the third-step success panel itself.
+ * Default `false` preserves the existing inline-panel rendering for the
+ * standalone `/mobile-app-login` page.
+ */
+ suppressInlinePanels?: boolean;
+};
+
+export const QRDirectLoginCode = ( {
+ onDone,
+ onConsumed,
+ suppressInlinePanels = false,
+}: QRDirectLoginCodeProps ) => {
+ // Tracks whether _displayed has already fired for this mount so that
+ // subsequent successful refreshes (which re-enter the READY state) only
+ // emit _refreshed and don't over-count first-displays in the funnel.
+ const displayedTrackedRef = useRef( false );
+ const availability = useQRLoginAvailability();
+ const {
+ state,
+ qrUrl,
+ secondsRemaining,
+ errorMessage,
+ errorCode,
+ deviceInfo,
+ apUuid,
+ candidateNumbers,
+ challengeExpiresAt,
+ chooseNumber,
+ fetchToken,
+ refreshToken,
+ revoke,
+ } = useQRLoginToken( {
+ onReady: () => {
+ if ( displayedTrackedRef.current ) {
+ return;
+ }
+ displayedTrackedRef.current = true;
+ recordEvent( 'mobile_app_qr_direct_login_displayed' );
+ },
+ onError: ( nextErrorCode ) => {
+ recordEvent( 'mobile_app_qr_direct_login_failed', {
+ error_code: nextErrorCode,
+ } );
+ },
+ } );
+
+ useEffect( () => {
+ // Don't even attempt to mint a token until we've heard back from
+ // `/qr-login-availability`. If the feature is unavailable, never
+ // fetch — `<QRLoginUnavailableCard />` owns the rendered state.
+ if ( availability.isLoading || ! availability.available ) {
+ return;
+ }
+ fetchToken();
+ }, [ availability.isLoading, availability.available, fetchToken ] );
+
+ // Bubble the consumed snapshot up to the parent so it can advance its
+ // own stepper to the third step. Standalone surfaces don't pass
+ // `onConsumed` and keep using the inline `QRLoginConsumedPanel`.
+ useEffect( () => {
+ if ( state === QRLoginTokenStates.CONSUMED && onConsumed ) {
+ onConsumed( { apUuid, deviceInfo } );
+ }
+ }, [ state, apUuid, deviceInfo, onConsumed ] );
+
+ const formatTime = ( seconds: number ) => {
+ const mins = Math.floor( seconds / 60 );
+ const secs = seconds % 60;
+ return `${ mins }:${ secs.toString().padStart( 2, '0' ) }`;
+ };
+
+ const renderRecoveryFallback = (
+ message: string,
+ buttonLabel: string,
+ eventName: string
+ ) => (
+ <div className="woocommerce-qr-direct-login">
+ <p className="woocommerce-qr-direct-login__error" role="alert">
+ { message }
+ </p>
+ <Button
+ variant="secondary"
+ onClick={ () => {
+ recordEvent( eventName );
+ refreshToken();
+ } }
+ >
+ { buttonLabel }
+ </Button>
+ </div>
+ );
+
+ // Up-front availability gate — render a brief loading state while the
+ // /qr-login-availability probe resolves, then either the disabled card
+ // (terminal) or fall through to the normal state machine below.
+ if ( availability.isLoading ) {
+ return (
+ <div className="woocommerce-qr-direct-login">
+ <Spinner />
+ <p role="status" aria-live="polite">
+ { __( 'Checking sign-in availability…', 'woocommerce' ) }
+ </p>
+ </div>
+ );
+ }
+
+ if ( ! availability.available ) {
+ return <QRLoginUnavailableCard reason={ availability.reason } />;
+ }
+
+ if ( state === QRLoginTokenStates.LOADING ) {
+ return (
+ <div className="woocommerce-qr-direct-login">
+ <Spinner />
+ <p role="status" aria-live="polite">
+ { __( 'Generating secure login code…', 'woocommerce' ) }
+ </p>
+ </div>
+ );
+ }
+
+ if ( state === QRLoginTokenStates.ERROR ) {
+ // "Try again" only makes sense when retrying could succeed. For
+ // `application_passwords_unavailable` the failure is structural —
+ // nothing about retrying changes the site's AP configuration — so
+ // suppress the retry button and rely on the error message (which
+ // already carries the docs link) to explain.
+ const isStructuralAPFailure =
+ errorCode === 'application_passwords_unavailable';
+
+ return (
+ <div className="woocommerce-qr-direct-login">
+ <p
+ className="woocommerce-qr-direct-login__error"
+ role="status"
+ aria-live="polite"
+ >
+ { errorMessage }
+ </p>
+ { ! isStructuralAPFailure && (
+ <Button
+ variant="secondary"
+ onClick={ () => {
+ recordEvent(
+ 'mobile_app_qr_direct_login_refreshed'
+ );
+ refreshToken();
+ } }
+ >
+ { __( 'Try again', 'woocommerce' ) }
+ </Button>
+ ) }
+ </div>
+ );
+ }
+
+ if ( state === QRLoginTokenStates.EXPIRED ) {
+ return (
+ <div className="woocommerce-qr-direct-login">
+ <p role="status" aria-live="polite">
+ { __( 'The login code has expired.', 'woocommerce' ) }
+ </p>
+ <Button
+ variant="secondary"
+ onClick={ () => {
+ recordEvent( 'mobile_app_qr_direct_login_refreshed' );
+ refreshToken();
+ } }
+ >
+ { __( 'Generate new code', 'woocommerce' ) }
+ </Button>
+ </div>
+ );
+ }
+
+ // Task 7 — number-matching states.
+ if ( state === QRLoginTokenStates.SCANNED && candidateNumbers ) {
+ return (
+ <QRLoginNumberMatchStep
+ numbers={ candidateNumbers }
+ deviceInfo={ deviceInfo }
+ challengeExpiresAt={ challengeExpiresAt }
+ onChooseNumber={ chooseNumber }
+ errorMessage={ errorMessage }
+ />
+ );
+ }
+
+ if ( state === QRLoginTokenStates.SCANNED ) {
+ return renderRecoveryFallback(
+ __(
+ 'We could not load the confirmation challenge. Please try again.',
+ 'woocommerce'
+ ),
+ __( 'Try again', 'woocommerce' ),
+ 'mobile_app_qr_direct_login_refreshed'
+ );
+ }
+
+ if ( state === QRLoginTokenStates.APPROVED ) {
+ return (
+ <div
+ className="woocommerce-qr-direct-login woocommerce-qr-direct-login--approved"
+ role="status"
+ aria-live="polite"
+ >
+ <Spinner />
+ <p>
+ { __(
+ 'Confirmed. Finishing sign-in on your phone…',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ );
+ }
+
+ if ( state === QRLoginTokenStates.REJECTED ) {
+ return (
+ <div
+ className="woocommerce-qr-direct-login woocommerce-qr-direct-login--rejected"
+ role="alert"
+ >
+ <p>
+ { __(
+ 'Sign-in denied. For your security, this attempt has been cancelled.',
+ 'woocommerce'
+ ) }
+ </p>
+ <Button
+ variant="secondary"
+ onClick={ () => {
+ recordEvent( 'mobile_app_qr_direct_login_refreshed' );
+ refreshToken();
+ } }
+ >
+ { __( 'Start over', 'woocommerce' ) }
+ </Button>
+ </div>
+ );
+ }
+
+ if ( state === QRLoginTokenStates.CONSUMED ) {
+ if ( suppressInlinePanels ) {
+ return null;
+ }
+ return (
+ <QRLoginConsumedPanel
+ deviceInfo={ deviceInfo }
+ onRevoke={ revoke }
+ onDone={ onDone }
+ errorMessage={ errorMessage }
+ />
+ );
+ }
+
+ if ( state === QRLoginTokenStates.REVOKED ) {
+ if ( suppressInlinePanels ) {
+ return null;
+ }
+ return <QRLoginRevokedPanel onDone={ onDone } />;
+ }
+
+ if ( state === QRLoginTokenStates.READY && qrUrl ) {
+ return (
+ <div className="woocommerce-qr-direct-login woocommerce-qr-direct-login--ready">
+ <div className="woocommerce-qr-direct-login__qr">
+ <QRCodeSVG value={ qrUrl } size={ 140 } />
+ </div>
+ <div className="woocommerce-qr-direct-login__meta">
+ { /* Countdown stays outside any live region so screen
+ readers don't re-announce it every second. */ }
+ <p
+ className="woocommerce-qr-direct-login__timer"
+ aria-live="off"
+ >
+ { sprintf(
+ /* translators: %s: time remaining in M:SS format */
+ __( 'Code expires in %s', 'woocommerce' ),
+ formatTime( secondsRemaining )
+ ) }
+ </p>
+ { /*
+ Persistent renew button — always visible while a code is
+ on screen. Lets a merchant who tabbed away mint a fresh
+ code without waiting for the 5-min countdown to finish.
+ */ }
+ <Button
+ variant="link"
+ className="woocommerce-qr-direct-login__renew"
+ onClick={ () => {
+ recordEvent( 'mobile_app_qr_direct_login_renewed' );
+ refreshToken();
+ } }
+ >
+ { __( 'Renew code', 'woocommerce' ) }
+ </Button>
+ </div>
+ </div>
+ );
+ }
+
+ if ( state === QRLoginTokenStates.READY ) {
+ return renderRecoveryFallback(
+ __(
+ 'We could not generate the login code. Please renew and try again.',
+ 'woocommerce'
+ ),
+ __( 'Renew code', 'woocommerce' ),
+ 'mobile_app_qr_direct_login_renewed'
+ );
+ }
+
+ return null;
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginConsumedPanel.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginConsumedPanel.tsx
new file mode 100644
index 00000000000..9b834f29dff
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginConsumedPanel.tsx
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import React from '@wordpress/element';
+import type { ReactNode } from 'react';
+import { Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import type { QRLoginDeviceInfo } from './useQRLoginToken';
+import {
+ buildQRLoginDeviceHeadline,
+ buildQRLoginDeviceSubline,
+} from './qrLoginDeviceCopy';
+
+type QRLoginConsumedPanelProps = {
+ deviceInfo: QRLoginDeviceInfo | null;
+ onRevoke: () => void;
+ onDone?: () => void;
+ errorMessage?: ReactNode | null;
+};
+
+/**
+ * Confirmation panel shown in place of the QR code once the mobile app has
+ * exchanged the token for an Application Password. Surfaces what device
+ * signed in (so the merchant can spot a wrong-device scan) and offers an
+ * "It wasn't you?" path that revokes the AP server-side.
+ */
+export const QRLoginConsumedPanel = ( {
+ deviceInfo,
+ onRevoke,
+ onDone,
+ errorMessage,
+}: QRLoginConsumedPanelProps ) => {
+ const headline = buildQRLoginDeviceHeadline( deviceInfo );
+ const subline = buildQRLoginDeviceSubline( deviceInfo );
+
+ return (
+ <div
+ className="woocommerce-qr-direct-login woocommerce-qr-direct-login--consumed"
+ role="status"
+ aria-live="polite"
+ >
+ <p className="woocommerce-qr-direct-login__consumed-headline">
+ { headline }
+ </p>
+ { subline && (
+ <p className="woocommerce-qr-direct-login__consumed-subline">
+ { subline }
+ </p>
+ ) }
+
+ { errorMessage && (
+ <p className="woocommerce-qr-direct-login__error" role="alert">
+ { errorMessage }
+ </p>
+ ) }
+
+ { onDone && (
+ <Button variant="primary" onClick={ onDone }>
+ { __( 'Done', 'woocommerce' ) }
+ </Button>
+ ) }
+
+ <Button
+ variant="link"
+ className="woocommerce-qr-direct-login__revoke"
+ onClick={ () => {
+ recordEvent( 'mobile_app_qr_direct_login_revoke_attempt' );
+ onRevoke();
+ } }
+ >
+ { __( "It wasn't you? Revoke access", 'woocommerce' ) }
+ </Button>
+ </div>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginNumberMatchStep.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginNumberMatchStep.tsx
new file mode 100644
index 00000000000..f9013fa9e61
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginNumberMatchStep.tsx
@@ -0,0 +1,233 @@
+/**
+ * External dependencies
+ */
+import React, { useEffect, useMemo, useState } from '@wordpress/element';
+import { Button, Spinner } from '@wordpress/components';
+import { sprintf, __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
+import type { ReactNode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type { QRLoginDeviceInfo } from './useQRLoginToken';
+import { buildQRLoginDeviceLine } from './qrLoginDeviceCopy';
+
+type QRLoginNumberMatchStepProps = {
+ /**
+ * Shuffled candidate triple as returned by /qr-login-status while the
+ * underlying token is in the SCANNED state. The wc-admin client never
+ * knows which one is the real one — server compares constant-time.
+ */
+ numbers: [ string, string, string ];
+ /**
+ * Device info reported by the mobile app on /qr-login-scan. Surfaced so
+ * the merchant can spot a wrong-device scan before approving.
+ */
+ deviceInfo: QRLoginDeviceInfo | null;
+ /**
+ * Unix-seconds timestamp at which the challenge expires (90s after scan).
+ * Drives the in-step countdown and disables the tiles when it hits zero.
+ */
+ challengeExpiresAt: number;
+ /**
+ * Submit a number choice — server will return approved or rejected. Empty
+ * string is the explicit cancel sentinel from the "It wasn't me" link.
+ */
+ onChooseNumber: ( choice: string ) => Promise< void > | void;
+ /**
+ * Optional error surfaced after an approval request fails without a state
+ * transition.
+ */
+ errorMessage?: ReactNode | null;
+};
+
+/**
+ * Number-match approval step — Microsoft Authenticator-style coincidence
+ * verification. The mobile app is showing the merchant a 3-digit number; we
+ * render that same number plus two distractors (server-shuffled). The
+ * merchant has to tap the matching one.
+ *
+ * One strike: a wrong tap (or the "It wasn't me" cancel link) terminates the
+ * session permanently. There is no retry — that's the entire point of the
+ * security guarantee. A 1-in-3 brute-force is not viable when paired with the
+ * single-attempt rule and the 90-second window.
+ *
+ * All three tiles are disabled while a click is in flight. We don't want a
+ * fast double-click registering as two attempts — that would race the state
+ * transition on the server side and pessimize the UX.
+ */
+export const QRLoginNumberMatchStep = ( {
+ numbers,
+ deviceInfo,
+ challengeExpiresAt,
+ onChooseNumber,
+ errorMessage = null,
+}: QRLoginNumberMatchStepProps ) => {
+ const [ inFlight, setInFlight ] = useState( false );
+ // Tracks which choice is currently being submitted so we can render the
+ // busy state only on the tapped tile (or the cancel link). Empty-string
+ // sentinel matches the cancel path; null means nothing is in flight.
+ const [ pendingChoice, setPendingChoice ] = useState< string | null >(
+ null
+ );
+ const [ secondsRemaining, setSecondsRemaining ] = useState< number >( () =>
+ Math.max( 0, Math.floor( challengeExpiresAt - Date.now() / 1000 ) )
+ );
+
+ // Local countdown that mirrors the server's challenge expiry. Driven off
+ // challengeExpiresAt rather than a duration prop so it stays in sync if
+ // the parent re-mounts mid-window.
+ useEffect( () => {
+ const tick = () => {
+ setSecondsRemaining(
+ Math.max(
+ 0,
+ Math.floor( challengeExpiresAt - Date.now() / 1000 )
+ )
+ );
+ };
+ tick();
+ const id = setInterval( tick, 1000 );
+ return () => clearInterval( id );
+ }, [ challengeExpiresAt ] );
+
+ useEffect( () => {
+ recordEvent( 'mobile_app_qr_login_number_match_displayed' );
+ }, [] );
+
+ const deviceLine = useMemo(
+ () => buildQRLoginDeviceLine( deviceInfo ),
+ [ deviceInfo ]
+ );
+
+ const expired = secondsRemaining <= 0;
+ const tilesDisabled = inFlight || expired;
+
+ const handleChoose = async ( choice: string ) => {
+ if ( tilesDisabled ) {
+ return;
+ }
+
+ setInFlight( true );
+ setPendingChoice( choice );
+ recordEvent( 'mobile_app_qr_login_number_match_chosen' );
+
+ try {
+ await onChooseNumber( choice );
+ } finally {
+ // Note: even on a wrong pick the parent flips state to REJECTED
+ // and unmounts this component, so resetting `inFlight` is mostly
+ // defensive — it matters only if the request errors out without
+ // a state transition.
+ setInFlight( false );
+ setPendingChoice( null );
+ }
+ };
+
+ const headline = sprintf(
+ /* translators: %s: device summary, e.g. "Pixel 10 · Android 16 · App version 24.6". */
+ __( 'Match this number on %s', 'woocommerce' ),
+ deviceLine
+ );
+
+ return (
+ <div
+ className="woocommerce-qr-direct-login woocommerce-qr-direct-login--number-match"
+ role="group"
+ aria-label={ __( 'Confirm sign-in', 'woocommerce' ) }
+ >
+ <p className="woocommerce-qr-direct-login__match-headline">
+ { headline }
+ </p>
+ <p className="woocommerce-qr-direct-login__match-description">
+ { __(
+ 'Tap the number that matches what you see on your phone.',
+ 'woocommerce'
+ ) }
+ </p>
+
+ <div
+ className="woocommerce-qr-direct-login__number-tiles"
+ role="group"
+ aria-label={ __( 'Number-match candidates', 'woocommerce' ) }
+ >
+ { numbers.map( ( candidate, index ) => {
+ // Only the tile that was tapped shows the busy state.
+ // the other two stay disabled but un-spinnered so the
+ // merchant can see which one is being submitted.
+ const isThisTilePending =
+ inFlight && pendingChoice === candidate;
+ return (
+ <Button
+ key={ `${ candidate }-${ index }` }
+ variant="secondary"
+ className="woocommerce-qr-direct-login__number-tile"
+ disabled={ tilesDisabled }
+ aria-disabled={ tilesDisabled }
+ isBusy={ isThisTilePending }
+ aria-label={ sprintf(
+ /* translators: %s: 3-digit candidate number. */
+ __(
+ 'Confirm with the number %s',
+ 'woocommerce'
+ ),
+ candidate
+ ) }
+ onClick={ () => handleChoose( candidate ) }
+ >
+ { candidate }
+ </Button>
+ );
+ } ) }
+ </div>
+
+ <p
+ className="woocommerce-qr-direct-login__match-countdown"
+ aria-live={ expired ? 'polite' : 'off' }
+ >
+ { expired
+ ? __( 'This sign-in attempt has expired.', 'woocommerce' )
+ : sprintf(
+ /* translators: %d: seconds remaining before the challenge expires. */
+ __( 'Expires in %ds', 'woocommerce' ),
+ secondsRemaining
+ ) }
+ </p>
+
+ { errorMessage && (
+ <p className="woocommerce-qr-direct-login__error" role="alert">
+ { errorMessage }
+ </p>
+ ) }
+
+ <div className="woocommerce-qr-direct-login__match-cancel-row">
+ <p className="woocommerce-qr-direct-login__match-cancel-text">
+ { __( "I don't recognise this device", 'woocommerce' ) }
+ </p>
+ <Button
+ variant="secondary"
+ className="woocommerce-qr-direct-login__match-cancel-button"
+ disabled={ tilesDisabled }
+ onClick={ () => {
+ recordEvent(
+ 'mobile_app_qr_login_number_match_cancelled'
+ );
+ // Empty string is treated by the server as a non-matching
+ // pick — same one-strike rejection path as a wrong tap.
+ handleChoose( '' );
+ } }
+ >
+ { inFlight && pendingChoice === '' ? (
+ <>
+ <Spinner />
+ <span>{ __( 'Cancelling…', 'woocommerce' ) }</span>
+ </>
+ ) : (
+ __( 'Cancel login', 'woocommerce' )
+ ) }
+ </Button>
+ </div>
+ </div>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginRevokedPanel.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginRevokedPanel.tsx
new file mode 100644
index 00000000000..c6841dde2a7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginRevokedPanel.tsx
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import React from '@wordpress/element';
+import { Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+type QRLoginRevokedPanelProps = {
+ onDone?: () => void;
+};
+
+/**
+ * Final confirmation panel shown after the merchant clicks "It wasn't you?"
+ * and the Application Password has been deleted server-side. The mobile app's
+ * next request will fail with 401 and the device will be effectively signed
+ * out the moment it tries to do anything.
+ */
+export const QRLoginRevokedPanel = ( { onDone }: QRLoginRevokedPanelProps ) => {
+ return (
+ <div
+ className="woocommerce-qr-direct-login woocommerce-qr-direct-login--revoked"
+ role="status"
+ aria-live="polite"
+ >
+ <p className="woocommerce-qr-direct-login__revoked-headline">
+ { __( 'Access revoked', 'woocommerce' ) }
+ </p>
+ <p className="woocommerce-qr-direct-login__revoked-subline">
+ { __(
+ 'The mobile app will be signed out the next time it makes a request.',
+ 'woocommerce'
+ ) }
+ </p>
+
+ { onDone && (
+ <Button variant="primary" onClick={ onDone }>
+ { __( 'Done', 'woocommerce' ) }
+ </Button>
+ ) }
+ </div>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginSuccessStep.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginSuccessStep.tsx
new file mode 100644
index 00000000000..14018e9b5ec
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginSuccessStep.tsx
@@ -0,0 +1,154 @@
+/**
+ * External dependencies
+ */
+import React, { useState } from '@wordpress/element';
+import { Button, Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { useRevokeQRLoginAccess } from './useRevokeQRLoginAccess';
+import type { QRLoginDeviceInfo } from './useQRLoginToken';
+import { buildQRLoginDeviceLine } from './qrLoginDeviceCopy';
+
+type QRLoginSuccessStepProps = {
+ apUuid: string | null;
+ deviceInfo: QRLoginDeviceInfo | null;
+};
+
+/**
+ * Step 3 of the modal flow — shown after the mobile app exchanges the QR
+ * token for an Application Password.
+ *
+ * Shows the device that signed in on a single line (device details + app
+ * version), mirroring the standalone flow's `QRLoginConsumedPanel`. Alongside
+ * it sits the "It wasn't you? Revoke access" pair, which sits on one row to
+ * make better use of the vertical space the stepper already consumes.
+ *
+ * The "Revoke access" CTA opens a confirmation modal before issuing the
+ * DELETE call — a stray click should not silently sign the merchant out of
+ * their own phone.
+ */
+export const QRLoginSuccessStep = ( {
+ apUuid,
+ deviceInfo,
+}: QRLoginSuccessStepProps ) => {
+ const deviceDetails = buildQRLoginDeviceLine( deviceInfo );
+ const [ isConfirmingRevoke, setIsConfirmingRevoke ] =
+ useState< boolean >( false );
+ const { revoke, isRevoking, isRevoked, errorMessage } =
+ useRevokeQRLoginAccess();
+
+ const openConfirmDialog = () => {
+ recordEvent( 'mobile_app_qr_direct_login_revoke_intent' );
+ setIsConfirmingRevoke( true );
+ };
+
+ const closeConfirmDialog = () => {
+ if ( isRevoking ) {
+ return;
+ }
+ setIsConfirmingRevoke( false );
+ };
+
+ const confirmRevoke = async () => {
+ if ( ! apUuid ) {
+ return;
+ }
+ recordEvent( 'mobile_app_qr_direct_login_revoke_attempt' );
+ await revoke( apUuid );
+ };
+
+ if ( isRevoked ) {
+ return (
+ <div
+ className="qr-login-success-step qr-login-success-step--revoked"
+ role="status"
+ aria-live="polite"
+ >
+ <h2 className="qr-login-success-step__heading">
+ { __( 'Access revoked', 'woocommerce' ) }
+ </h2>
+ <p className="qr-login-success-step__description">
+ { __(
+ 'The mobile app will be signed out the next time it makes a request.',
+ 'woocommerce'
+ ) }
+ </p>
+ </div>
+ );
+ }
+
+ return (
+ <>
+ <div
+ className="qr-login-success-step"
+ role="status"
+ aria-live="polite"
+ >
+ { deviceDetails && (
+ <p className="qr-login-success-step__device-details">
+ { deviceDetails }
+ </p>
+ ) }
+
+ <div className="qr-login-success-step__revoke-row">
+ <p className="qr-login-success-step__challenge">
+ { __( "It wasn't you?", 'woocommerce' ) }
+ </p>
+ <Button
+ variant="primary"
+ className="qr-login-success-step__revoke-button"
+ onClick={ openConfirmDialog }
+ disabled={ ! apUuid }
+ >
+ { __( 'Revoke access', 'woocommerce' ) }
+ </Button>
+ </div>
+
+ { errorMessage && (
+ <p className="qr-login-success-step__error" role="alert">
+ { errorMessage }
+ </p>
+ ) }
+ </div>
+
+ { isConfirmingRevoke && (
+ <Modal
+ title={ __( 'Revoke access?', 'woocommerce' ) }
+ onRequestClose={ closeConfirmDialog }
+ className="qr-login-success-step__confirm-modal"
+ shouldCloseOnEsc={ ! isRevoking }
+ shouldCloseOnClickOutside={ ! isRevoking }
+ >
+ <p>
+ { __(
+ 'The mobile app will be signed out the next time it tries to reach your store. You can sign in again any time by scanning a new QR code.',
+ 'woocommerce'
+ ) }
+ </p>
+ <div className="qr-login-success-step__confirm-actions">
+ <Button
+ variant="tertiary"
+ onClick={ closeConfirmDialog }
+ disabled={ isRevoking }
+ >
+ { __( 'Cancel', 'woocommerce' ) }
+ </Button>
+ <Button
+ variant="primary"
+ onClick={ confirmRevoke }
+ isBusy={ isRevoking }
+ disabled={ isRevoking }
+ className="qr-login-success-step__confirm-revoke-button"
+ >
+ { __( 'Revoke access', 'woocommerce' ) }
+ </Button>
+ </div>
+ </Modal>
+ ) }
+ </>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginUnavailableCard.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginUnavailableCard.tsx
new file mode 100644
index 00000000000..7a1d0ce6fbe
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/QRLoginUnavailableCard.tsx
@@ -0,0 +1,110 @@
+/**
+ * External dependencies
+ */
+import { Notice } from '@wordpress/components';
+import { createInterpolateElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Link } from '@woocommerce/components';
+import type { ReactNode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import {
+ QRLoginUnavailableReasons,
+ type QRLoginUnavailableReason,
+} from './useQRLoginAvailability';
+
+/**
+ * WordPress documentation on application passwords. Centralized here (and in
+ * useQRLoginToken.tsx) so the URL is easy to refresh when the docs move.
+ */
+const APPLICATION_PASSWORDS_DOCS_URL =
+ 'https://developer.wordpress.org/advanced-administration/security/application-passwords/';
+
+/**
+ * Permanently-disabled QR card. Rendered when `/qr-login-availability` reports
+ * `available: false` so the merchant gets a clear up-front explanation instead
+ * of mounting the QR component, spinning, hitting `/qr-login-token`, and only
+ * then seeing a generic error.
+ */
+export const QRLoginUnavailableCard = ( {
+ reason,
+}: {
+ reason: QRLoginUnavailableReason | null;
+} ) => {
+ // Each reason gets its own headline so the merchant can act on it. The
+ // AP-disabled-by-filter case is the most common third-party-plugin
+ // scenario; the AP-unsupported and HTTPS branches are typically infra
+ // setup issues. All branches share the docs link because the diagnostic
+ // flow is the same regardless.
+ let headline: ReactNode;
+ if ( reason === QRLoginUnavailableReasons.HTTPS_REQUIRED ) {
+ headline = __(
+ 'QR sign-in is unavailable because this site is not served over HTTPS. Application passwords require an HTTPS connection.',
+ 'woocommerce'
+ );
+ } else {
+ // AP unsupported or filtered off — the merchant-facing distinction is
+ // blurry, so we share one message and let the docs link carry the
+ // explanation.
+ headline = createInterpolateElement(
+ __(
+ 'Mobile login is unavailable if application passwords are disabled on your site. Find more about application passwords <link>here</link>.',
+ 'woocommerce'
+ ),
+ {
+ link: (
+ <Link
+ href={ APPLICATION_PASSWORDS_DOCS_URL }
+ target="_blank"
+ type="external"
+ />
+ ),
+ }
+ );
+ }
+
+ return (
+ <div className="woocommerce-qr-direct-login woocommerce-qr-direct-login--unavailable">
+ <Notice
+ className="woocommerce-qr-direct-login__unavailable-notice"
+ status="warning"
+ isDismissible={ false }
+ >
+ { headline }
+ </Notice>
+
+ { /*
+ Native <details> — full keyboard + screen-reader support out
+ of the box, and the collapsed state keeps the headline
+ scannable.
+ */ }
+ <details className="woocommerce-qr-direct-login__why">
+ <summary>
+ { __( 'Why am I seeing this?', 'woocommerce' ) }
+ </summary>
+ <ul>
+ <li>
+ { __(
+ 'A security plugin (e.g. Wordfence, Solid Security, iThemes Security) may have disabled application passwords.',
+ 'woocommerce'
+ ) }
+ </li>
+ <li>
+ { __(
+ 'A custom code snippet using the wp_is_application_passwords_available filter may have disabled them.',
+ 'woocommerce'
+ ) }
+ </li>
+ <li>
+ { __(
+ 'On most hosts, application passwords also require an HTTPS connection.',
+ 'woocommerce'
+ ) }
+ </li>
+ </ul>
+ </details>
+ </div>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/_qr-direct-login.scss b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/_qr-direct-login.scss
new file mode 100644
index 00000000000..263065c6f12
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/_qr-direct-login.scss
@@ -0,0 +1,236 @@
+// Shared <QRDirectLoginCode /> styles. Imported by both the homescreen
+// mobile-app modal (`mobile-app-modal/style.scss`) and the standalone
+// wc-admin page (`mobile-app-login/style.scss`) so the QR / number-match /
+// approved / rejected layouts render identically on either surface.
+// Modal-only panels (success step, revoke confirmation) stay in the modal
+// stylesheet — they aren't rendered by the standalone page.
+
+// QR + meta column layout while the code is on screen (READY state only).
+// We avoid scoping these rules to `.woocommerce-qr-direct-login` itself so the LOADING /
+// EXPIRED / ERROR states keep their default stacked layout.
+.woocommerce-qr-direct-login.woocommerce-qr-direct-login--ready {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 24px;
+ flex-wrap: wrap;
+
+ .woocommerce-qr-direct-login__qr {
+ flex: 0 0 auto;
+ display: flex;
+ }
+
+ .woocommerce-qr-direct-login__meta {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ min-width: 0;
+ }
+
+ .woocommerce-qr-direct-login__timer {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: $gray-900;
+ }
+}
+
+.woocommerce-qr-direct-login__error {
+ margin: 0 0 12px;
+ color: $alert-red;
+ font-size: 13px;
+ line-height: 1.4;
+}
+
+// Task 7 — number-matching approval step. Replaces the QR while in the
+// SCANNED state: the merchant taps the 3-digit tile that matches what the
+// mobile app is showing, server validates with hash_equals(), and the flow
+// either advances to APPROVED → CONSUMED or terminates at REJECTED.
+.woocommerce-qr-direct-login.woocommerce-qr-direct-login--number-match {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+
+ .woocommerce-qr-direct-login__match-headline {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.5;
+ color: $gray-900;
+ margin: 0;
+ }
+
+ .woocommerce-qr-direct-login__match-description {
+ font-size: 13px;
+ line-height: 1.5;
+ color: $gray-700;
+ margin: 0 0 4px;
+ }
+
+ .woocommerce-qr-direct-login__number-tiles {
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
+ // Tiles row centers under the heading. Both surfaces (modal + standalone)
+ // otherwise center every other element in this column, so a left-anchored
+ // row reads as a layout bug.
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+
+ button.components-button.woocommerce-qr-direct-login__number-tile {
+ flex: 0 0 auto;
+ min-width: 92px;
+ height: 64px;
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: 0;
+ color: $gray-900;
+ background: $studio-white;
+ border: 1px solid #c3c4c7;
+ border-radius: 6px;
+ padding: 0 16px;
+ justify-content: center;
+ // Each tile is its own click target — disable the secondary-button
+ // hover that ships in WP for visual cohesion.
+ &:hover:not(:disabled) {
+ background: #f6f7f7;
+ border-color: $gray-900;
+ }
+ &:focus:not(:disabled) {
+ border-color: #007cba;
+ box-shadow: 0 0 0 1px #007cba;
+ outline: 2px solid transparent;
+ }
+ &[disabled],
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+ }
+
+ .woocommerce-qr-direct-login__match-countdown {
+ font-size: 13px;
+ line-height: 1.5;
+ color: $gray-700;
+ margin: 0;
+ }
+
+ // "I don't recognise this device" + outlined "Cancel login" button row.
+ // We pair an explanatory line with a real button rather than rendering a
+ // red underlined link. A bare link doesn't read as the "wrong device,
+ // terminate session" affordance the merchant needs at this point in the
+ // flow, and visually it competes with the three number tiles above.
+ .woocommerce-qr-direct-login__match-cancel-row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+
+ .woocommerce-qr-direct-login__match-cancel-text {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: $gray-700;
+ }
+
+ button.components-button.woocommerce-qr-direct-login__match-cancel-button {
+ // Inline spinner + label. The Spinner component renders a small
+ // circle; this gap prevents it from kissing the "Cancelling…" text.
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ // WP's default Spinner has a margin tuned for standalone use. Zero it
+ // so the flex `gap` is the single source of spacing inside the button.
+ .components-spinner {
+ margin: 0;
+ }
+ }
+}
+
+// APPROVED transitional state — small inline spinner + caption while the
+// mobile app finishes the exchange. Stays compact so the modal layout
+// doesn't jump between SCANNED and CONSUMED.
+.woocommerce-qr-direct-login.woocommerce-qr-direct-login--approved {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: $gray-900;
+ }
+}
+
+// REJECTED terminal state. The "Sign-in denied" message and the Start over
+// button stack centered to match the rest of the column on both surfaces.
+.woocommerce-qr-direct-login.woocommerce-qr-direct-login--rejected {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ text-align: center;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ color: $gray-900;
+ }
+}
+
+// UNAVAILABLE state. Rendered when /qr-login-availability returns
+// `available: false` — typically because a security plugin or a custom
+// filter has disabled application passwords. Stacked column layout matches
+// the other terminal states (REJECTED, EXPIRED) for visual consistency.
+.woocommerce-qr-direct-login.woocommerce-qr-direct-login--unavailable {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 16px;
+
+ .woocommerce-qr-direct-login__unavailable-notice {
+ // The default Notice has a left margin from the WP admin chrome
+ // that doesn't fit inside the modal column, so strip it. The
+ // Notice also centers its body text by default; left-align to match
+ // the rest of the column.
+ margin: 0;
+ text-align: left;
+ }
+
+ .woocommerce-qr-direct-login__why {
+ font-size: 13px;
+ line-height: 1.5;
+ color: #50575e;
+
+ // The summary is the disclosure trigger, not a link — keep it as
+ // regular text colour so it doesn't masquerade as one. The native
+ // arrow marker is enough of an affordance.
+ summary {
+ cursor: pointer;
+ user-select: none;
+ padding: 4px 0;
+ color: #1d2327;
+ font-weight: 500;
+ }
+
+ ul {
+ margin: 8px 0 0 18px;
+ padding: 0;
+ list-style: disc;
+ }
+
+ li + li {
+ margin-top: 4px;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/index.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/index.tsx
index 1be00cfbec8..363afe30061 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/index.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/index.tsx
@@ -4,3 +4,16 @@ export {
} from './useJetpackPluginState';
export { useSendMagicLink, SendMagicLinkStates } from './useSendMagicLink';
export { SendMagicLinkButton } from './SendMagicLinkButton';
+export {
+ useQRLoginToken,
+ QRLoginTokenStates,
+ type QRLoginDeviceInfo,
+} from './useQRLoginToken';
+export {
+ QRDirectLoginCode,
+ type QRLoginConsumedSnapshot,
+} from './QRDirectLoginCode';
+export { QRLoginConsumedPanel } from './QRLoginConsumedPanel';
+export { QRLoginRevokedPanel } from './QRLoginRevokedPanel';
+export { QRLoginSuccessStep } from './QRLoginSuccessStep';
+export { useRevokeQRLoginAccess } from './useRevokeQRLoginAccess';
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/qrLoginDeviceCopy.ts b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/qrLoginDeviceCopy.ts
new file mode 100644
index 00000000000..359eac6a9db
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/qrLoginDeviceCopy.ts
@@ -0,0 +1,120 @@
+/**
+ * External dependencies
+ */
+import { sprintf, __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { QRLoginDeviceInfo } from './useQRLoginToken';
+
+/**
+ * Build the headline shown after a successful sign-in. The Task 7
+ * `/qr-login-scan` endpoint requires a device payload, so by the time we
+ * render we should have at least an OS label. The null guard covers brief
+ * React state handoff renders, not protocol compatibility.
+ */
+export const buildQRLoginDeviceHeadline = (
+ device: QRLoginDeviceInfo | null
+): string => {
+ const model = device?.model?.trim();
+ if ( model ) {
+ return sprintf(
+ /* translators: %s: device model, e.g. "iPhone 15". */
+ __( 'Signed in successfully on %s', 'woocommerce' ),
+ model
+ );
+ }
+
+ const os = device?.os?.trim();
+ if ( os ) {
+ return sprintf(
+ /* translators: %s: OS name, e.g. "iOS" or "Android". */
+ __( 'Signed in successfully on %s', 'woocommerce' ),
+ os
+ );
+ }
+
+ return __( 'Signed in successfully', 'woocommerce' );
+};
+
+/**
+ * Build a one-line device summary (model · OS version · app version). Skips
+ * any individual field the mobile app didn't send so we never render empty
+ * separators or `undefined` artifacts, and falls back to a generic "Mobile
+ * app" label only when nothing at all is available (a server contract bug or
+ * the brief pre-poll render window).
+ *
+ * Shared by the number-match step (step 2) and the success step (step 3) so
+ * both surface the same device line — including the model, which is the most
+ * recognizable field for spotting a wrong-device scan.
+ */
+export const buildQRLoginDeviceLine = (
+ device: QRLoginDeviceInfo | null
+): string => {
+ const parts: string[] = [];
+
+ const model = device?.model?.trim();
+ if ( model ) {
+ parts.push( model );
+ }
+
+ if ( device?.os ) {
+ parts.push(
+ device.os_version
+ ? `${ device.os } ${ device.os_version }`
+ : device.os
+ );
+ }
+
+ if ( device?.app_version ) {
+ parts.push(
+ sprintf(
+ /* translators: %s: mobile app version, e.g. "24.7.0". */
+ __( 'App version %s', 'woocommerce' ),
+ device.app_version
+ )
+ );
+ }
+
+ return parts.length > 0
+ ? parts.join( ' · ' )
+ : __( 'Mobile app', 'woocommerce' );
+};
+
+/**
+ * Build a one-line subline summarizing the OS/app the merchant signed in with,
+ * deliberately omitting the model because the standalone `QRLoginConsumedPanel`
+ * already names the model in its headline ("Signed in successfully on …").
+ * Skips any field the mobile app didn't send so we never render empty
+ * separators or `undefined` artifacts.
+ */
+export const buildQRLoginDeviceSubline = (
+ device: QRLoginDeviceInfo | null
+): string => {
+ if ( ! device ) {
+ return '';
+ }
+
+ const parts: string[] = [];
+
+ if ( device.os ) {
+ parts.push(
+ device.os_version
+ ? `${ device.os } ${ device.os_version }`
+ : device.os
+ );
+ }
+
+ if ( device.app_version ) {
+ parts.push(
+ sprintf(
+ /* translators: %s: mobile app version, e.g. "24.7.0". */
+ __( 'App version %s', 'woocommerce' ),
+ device.app_version
+ )
+ );
+ }
+
+ return parts.join( ' · ' );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/MobileAppLoginStepper.test.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/MobileAppLoginStepper.test.tsx
new file mode 100644
index 00000000000..dedf06edf67
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/MobileAppLoginStepper.test.tsx
@@ -0,0 +1,260 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { MobileAppLoginStepper } from '../MobileAppLoginStepper';
+import { SendMagicLinkStates } from '../useSendMagicLink';
+import { QRLoginTokenStates, useQRLoginToken } from '../useQRLoginToken';
+
+// Mock the QR login token hook so we can drive each state from the tests.
+jest.mock( '../useQRLoginToken', () => {
+ const actual = jest.requireActual( '../useQRLoginToken' );
+ return {
+ ...actual,
+ useQRLoginToken: jest.fn(),
+ };
+} );
+
+// Short-circuit the up-front availability probe — these tests focus on the
+// stepper's own gating + the underlying token state machine, not on the
+// availability gate (which is covered separately by useQRLoginAvailability's
+// own tests).
+jest.mock( '../useQRLoginAvailability', () => {
+ const actual = jest.requireActual( '../useQRLoginAvailability' );
+ return {
+ ...actual,
+ useQRLoginAvailability: () => ( {
+ isLoading: false,
+ available: true,
+ reason: null,
+ } ),
+ };
+} );
+
+// Mock tracks to keep tests isolated from analytics side-effects.
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+const mockedUseQRLoginToken = useQRLoginToken as jest.MockedFunction<
+ typeof useQRLoginToken
+>;
+
+const readyTokenState = {
+ state: QRLoginTokenStates.READY,
+ qrUrl: 'woocommerce://qr-login?token=abc&siteUrl=https%3A%2F%2Fexample.test',
+ secondsRemaining: 300,
+ errorMessage: null,
+ errorCode: null,
+ deviceInfo: null,
+ apUuid: null,
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ chooseNumber: jest.fn(),
+ fetchToken: jest.fn(),
+ refreshToken: jest.fn(),
+ revoke: jest.fn(),
+};
+
+const errorTokenState = {
+ state: QRLoginTokenStates.ERROR,
+ qrUrl: null,
+ secondsRemaining: 0,
+ errorMessage: 'QR login requires an HTTPS connection.',
+ errorCode: 'ssl_required',
+ deviceInfo: null,
+ apUuid: null,
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ chooseNumber: jest.fn(),
+ fetchToken: jest.fn(),
+ refreshToken: jest.fn(),
+ revoke: jest.fn(),
+};
+
+const baseProps = {
+ step: 'second' as const,
+ signInResult: null,
+ completeInstallationStepHandler: jest.fn(),
+ sendMagicLinkHandler: jest.fn(),
+ sendMagicLinkStatus: SendMagicLinkStates.INIT,
+ onSignedIn: jest.fn(),
+};
+
+describe( 'MobileAppLoginStepper', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockedUseQRLoginToken.mockReturnValue( readyTokenState );
+ } );
+
+ describe( 'step 2 (sign-in)', () => {
+ it( 'renders the QR direct login for an admin without Jetpack and hides the magic link button', () => {
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ isJetpackPluginInstalled={ false }
+ wordpressAccountEmailAddress={ undefined }
+ />
+ );
+
+ // The QR direct login is the primary path — its expiry timer copy
+ // is a reliable signal that the component is on screen.
+ expect(
+ screen.getByText( /Code expires in/i )
+ ).toBeInTheDocument();
+
+ // The WordPress.com magic link secondary CTA should not show up
+ // for non-Jetpack users.
+ expect(
+ screen.queryByText(
+ /Or get a WordPress\.com sign-in link by email/i
+ )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Send the sign-in link/i,
+ } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders both the QR and the magic link button when Jetpack is fully connected and the user has a linked WordPress.com account', () => {
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ isJetpackPluginInstalled={ true }
+ wordpressAccountEmailAddress="admin@example.test"
+ />
+ );
+
+ expect(
+ screen.getByText( /Code expires in/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Or get a WordPress\.com sign-in link by email/i
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', {
+ name: /Send the sign-in link/i,
+ } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'hides the magic link button for a shop manager without a linked WordPress.com account even if Jetpack is installed', () => {
+ // Shop managers typically don't own the Jetpack connection and
+ // their currentUser.wpcomUser.email is undefined upstream, which
+ // surfaces here as wordpressAccountEmailAddress === undefined.
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ isJetpackPluginInstalled={ true }
+ wordpressAccountEmailAddress={ undefined }
+ />
+ );
+
+ expect(
+ screen.getByText( /Code expires in/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ /Or get a WordPress\.com sign-in link by email/i
+ )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'invokes the magic link handler when the secondary button is clicked', () => {
+ const sendMagicLinkHandler = jest.fn();
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ sendMagicLinkHandler={ sendMagicLinkHandler }
+ isJetpackPluginInstalled={ true }
+ wordpressAccountEmailAddress="admin@example.test"
+ />
+ );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /Send the sign-in link/i,
+ } )
+ );
+
+ expect( sendMagicLinkHandler ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'surfaces the QR error state from useQRLoginToken', () => {
+ mockedUseQRLoginToken.mockReturnValue( errorTokenState );
+
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ isJetpackPluginInstalled={ false }
+ wordpressAccountEmailAddress={ undefined }
+ />
+ );
+
+ expect(
+ screen.getByText( /QR login requires an HTTPS connection/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: /Try again/i } )
+ ).toBeInTheDocument();
+ // Error state should never leak the happy-path timer copy.
+ expect(
+ screen.queryByText( /Code expires in/i )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'does not render the old username + site URL fallback when the user lacks a linked WordPress.com account', () => {
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ isJetpackPluginInstalled={ false }
+ wordpressAccountEmailAddress={ undefined }
+ />
+ );
+
+ // The removed MobileAppLoginInfo fallback used to show this copy.
+ expect(
+ screen.queryByText(
+ /Scan the QR code below and enter the wp-admin password/i
+ )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'step 1 (install)', () => {
+ it( 'renders the install-app CTA and wires it up to the handler', () => {
+ const completeInstallationStepHandler = jest.fn();
+ render(
+ <MobileAppLoginStepper
+ { ...baseProps }
+ step="first"
+ completeInstallationStepHandler={
+ completeInstallationStepHandler
+ }
+ isJetpackPluginInstalled={ false }
+ wordpressAccountEmailAddress={ undefined }
+ />
+ );
+
+ const installButton = screen.getByRole( 'button', {
+ name: /App is installed/i,
+ } );
+ fireEvent.click( installButton );
+ expect( completeInstallationStepHandler ).toHaveBeenCalledTimes(
+ 1
+ );
+
+ // The QR direct login is rendered for step 2, not step 1.
+ expect(
+ screen.queryByText( /Code expires in/i )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/QRLoginNumberMatchStep.test.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/QRLoginNumberMatchStep.test.tsx
new file mode 100644
index 00000000000..0feedacc642
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/QRLoginNumberMatchStep.test.tsx
@@ -0,0 +1,235 @@
+/**
+ * External dependencies
+ */
+import {
+ act,
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { QRLoginNumberMatchStep } from '../QRLoginNumberMatchStep';
+
+// Tracks is fire-and-forget here — we don't assert against it, just keep the
+// component's recordEvent calls from blowing up because no global window
+// shim is registered in jsdom.
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+const NOW_SECONDS = 1_700_000_000;
+const CHALLENGE_EXPIRES_AT = NOW_SECONDS + 90;
+
+const renderStep = (
+ overrides: Partial< Parameters< typeof QRLoginNumberMatchStep >[ 0 ] > = {}
+) => {
+ const onChooseNumber = jest.fn();
+ render(
+ <QRLoginNumberMatchStep
+ numbers={ [ '317', '042', '589' ] }
+ deviceInfo={ {
+ model: 'Pixel 10',
+ os: 'Android',
+ os_version: '16',
+ app_version: '24.7.0',
+ } }
+ challengeExpiresAt={ CHALLENGE_EXPIRES_AT }
+ onChooseNumber={ onChooseNumber }
+ { ...overrides }
+ />
+ );
+ return { onChooseNumber };
+};
+
+describe( 'QRLoginNumberMatchStep', () => {
+ beforeEach( () => {
+ jest.useFakeTimers();
+ jest.setSystemTime( NOW_SECONDS * 1000 );
+ } );
+
+ afterEach( () => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ } );
+
+ it( 'renders the three candidate numbers in the order received from the server', () => {
+ renderStep();
+
+ const tiles = screen.getAllByRole( 'button', {
+ name: /Confirm with the number/i,
+ } );
+ expect( tiles ).toHaveLength( 3 );
+ expect( tiles[ 0 ] ).toHaveTextContent( '317' );
+ expect( tiles[ 1 ] ).toHaveTextContent( '042' );
+ expect( tiles[ 2 ] ).toHaveTextContent( '589' );
+ } );
+
+ it( 'surfaces device model + OS + app version in the headline', () => {
+ renderStep();
+
+ expect(
+ screen.getByText( /Pixel 10 · Android 16 · App version 24\.7\.0/ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'falls back to "Mobile app" when no device info is provided', () => {
+ renderStep( { deviceInfo: null } );
+
+ expect(
+ screen.getByText( /Match this number on Mobile app/ )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'invokes onChooseNumber with the tapped value', async () => {
+ const { onChooseNumber } = renderStep();
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ );
+
+ expect( onChooseNumber ).toHaveBeenCalledWith( '042' );
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ ).not.toBeDisabled()
+ );
+ } );
+
+ /**
+ * One-strike rule: while a click is in flight, all three tiles must be
+ * disabled so a fast double-click can't register as two attempts and
+ * race the state transition on the server side.
+ */
+ it( 'disables all three tiles while a click is in flight', async () => {
+ let resolveChoice: () => void = () => undefined;
+ const onChooseNumber = jest.fn(
+ () =>
+ new Promise< void >( ( r ) => {
+ resolveChoice = r;
+ } )
+ );
+ render(
+ <QRLoginNumberMatchStep
+ numbers={ [ '317', '042', '589' ] }
+ deviceInfo={ null }
+ challengeExpiresAt={ CHALLENGE_EXPIRES_AT }
+ onChooseNumber={ onChooseNumber }
+ />
+ );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ );
+
+ await waitFor( () => {
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 317/i,
+ } )
+ ).toBeDisabled();
+ } );
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ ).toBeDisabled();
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 589/i,
+ } )
+ ).toBeDisabled();
+
+ // Subsequent click on a different tile while in flight is a no-op.
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 317/i,
+ } )
+ );
+ expect( onChooseNumber ).toHaveBeenCalledTimes( 1 );
+
+ resolveChoice();
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ ).not.toBeDisabled()
+ );
+ } );
+
+ it( 'cancel-login button calls onChooseNumber with the empty-string sentinel', async () => {
+ const { onChooseNumber } = renderStep();
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /cancel login/i } )
+ );
+
+ expect( onChooseNumber ).toHaveBeenCalledWith( '' );
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', { name: /cancel login/i } )
+ ).not.toBeDisabled()
+ );
+ } );
+
+ it( 'shows a 90-second countdown that ticks down each second', () => {
+ renderStep();
+
+ const countdown = screen.getByText( /Expires in 90s/ );
+ expect( countdown ).toBeInTheDocument();
+ expect( countdown ).toHaveAttribute( 'aria-live', 'off' );
+
+ // jest.useFakeTimers() with the modern impl ties Date.now() to the
+ // fake timer queue, so advancing the timer also advances the wall
+ // clock — no separate setSystemTime needed. Wrap in act() so the
+ // setSecondsRemaining update from the interval tick flushes before
+ // the next assertion runs.
+ act( () => {
+ jest.advanceTimersByTime( 1000 );
+ } );
+
+ expect( screen.getByText( /Expires in 89s/ ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders approval errors accessibly', () => {
+ renderStep( { errorMessage: 'Approval is already in progress.' } );
+
+ expect( screen.getByRole( 'alert' ) ).toHaveTextContent(
+ 'Approval is already in progress.'
+ );
+ } );
+
+ it( 'disables tiles and surfaces an expired message once the challenge window elapses', () => {
+ const { onChooseNumber } = renderStep( {
+ challengeExpiresAt: NOW_SECONDS,
+ } );
+
+ const expiredMessage = screen.getByText(
+ /This sign-in attempt has expired/
+ );
+ expect( expiredMessage ).toBeInTheDocument();
+ expect( expiredMessage ).toHaveAttribute( 'aria-live', 'polite' );
+ expect(
+ screen.getByRole( 'button', {
+ name: /Confirm with the number 042/i,
+ } )
+ ).toBeDisabled();
+ const cancelButton = screen.getByRole( 'button', {
+ name: /cancel login/i,
+ } );
+ expect( cancelButton ).toBeDisabled();
+
+ fireEvent.click( cancelButton );
+ expect( onChooseNumber ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginAvailability.test.ts b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginAvailability.test.ts
new file mode 100644
index 00000000000..a6f81d4472c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginAvailability.test.ts
@@ -0,0 +1,122 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import {
+ useQRLoginAvailability,
+ QRLoginUnavailableReasons,
+} from '../useQRLoginAvailability';
+
+jest.mock( '@wordpress/api-fetch' );
+
+const mockApiFetch = apiFetch as unknown as jest.MockedFunction<
+ ( options: { path: string; method: string } ) => Promise< unknown >
+>;
+
+describe( 'useQRLoginAvailability', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'starts in the loading state and resolves to available on a positive response', async () => {
+ let resolveFetch:
+ | ( ( value: { available: boolean; reason: null } ) => void )
+ | undefined;
+ mockApiFetch.mockReturnValue(
+ new Promise( ( resolve ) => {
+ resolveFetch = resolve;
+ } )
+ );
+
+ const { result, waitForNextUpdate } = renderHook( () =>
+ useQRLoginAvailability()
+ );
+
+ // Synchronously after mount the probe is still in flight.
+ expect( result.current.isLoading ).toBe( true );
+ expect( result.current.available ).toBe( false );
+ expect( result.current.reason ).toBeNull();
+
+ await act( async () => {
+ resolveFetch?.( { available: true, reason: null } );
+ await waitForNextUpdate();
+ } );
+
+ expect( result.current.isLoading ).toBe( false );
+ expect( result.current.available ).toBe( true );
+ expect( result.current.reason ).toBeNull();
+ } );
+
+ it( 'resolves to unavailable + a reason code when the server reports the feature off', async () => {
+ mockApiFetch.mockResolvedValue( {
+ available: false,
+ reason: QRLoginUnavailableReasons.APPLICATION_PASSWORDS_DISABLED_BY_FILTER,
+ } );
+
+ const { result, waitForNextUpdate } = renderHook( () =>
+ useQRLoginAvailability()
+ );
+
+ await act( async () => {
+ await waitForNextUpdate();
+ } );
+
+ expect( result.current.isLoading ).toBe( false );
+ expect( result.current.available ).toBe( false );
+ expect( result.current.reason ).toBe(
+ QRLoginUnavailableReasons.APPLICATION_PASSWORDS_DISABLED_BY_FILTER
+ );
+ } );
+
+ it( 'falls through to optimistic-available on a network failure so the existing token-fetch path takes over', async () => {
+ mockApiFetch.mockRejectedValue( new Error( 'network down' ) );
+
+ const { result, waitForNextUpdate } = renderHook( () =>
+ useQRLoginAvailability()
+ );
+
+ await act( async () => {
+ await waitForNextUpdate();
+ } );
+
+ expect( result.current.isLoading ).toBe( false );
+ expect( result.current.available ).toBe( true );
+ expect( result.current.reason ).toBeNull();
+ } );
+
+ it( 'is defensive against unexpected response shapes (still falls through to optimistic-available)', async () => {
+ mockApiFetch.mockResolvedValue( { foo: 'bar' } );
+
+ const { result, waitForNextUpdate } = renderHook( () =>
+ useQRLoginAvailability()
+ );
+
+ await act( async () => {
+ await waitForNextUpdate();
+ } );
+
+ expect( result.current.isLoading ).toBe( false );
+ expect( result.current.available ).toBe( true );
+ } );
+
+ it( 'hits the expected REST path', async () => {
+ mockApiFetch.mockResolvedValue( { available: true, reason: null } );
+
+ const { waitForNextUpdate } = renderHook( () =>
+ useQRLoginAvailability()
+ );
+ await act( async () => {
+ await waitForNextUpdate();
+ } );
+
+ expect( mockApiFetch ).toHaveBeenCalledWith( {
+ path: '/wc-admin/mobile-app/qr-login-availability',
+ method: 'GET',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginToken.test.ts b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginToken.test.ts
new file mode 100644
index 00000000000..cfaaf23e6f2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/test/useQRLoginToken.test.ts
@@ -0,0 +1,859 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { QRLoginTokenStates, useQRLoginToken } from '../useQRLoginToken';
+
+jest.mock( '@wordpress/api-fetch' );
+
+const mockApiFetch = apiFetch as unknown as jest.MockedFunction<
+ ( options: { path: string; method: string } ) => Promise< unknown >
+>;
+
+// A fixed "now" keeps the countdown math deterministic across tests.
+const NOW_SECONDS = 1_700_000_000;
+const TTL_SECONDS = 300;
+
+const buildResponse = ( ttl: number = TTL_SECONDS, token = 'abc' ) => ( {
+ qr_url: `woocommerce://qr-login?token=${ token }&siteUrl=https%3A%2F%2Fexample.test&ttl=${ ttl }`,
+ expires_at: NOW_SECONDS + ttl,
+ ttl,
+} );
+
+// Mock `wpcom_account_required` is intentionally *not* listed here — the
+// backend no longer returns it after WOOMOB-2764 and the hook no longer
+// branches on it. If it ever shows up again we want it to fall through to
+// the generic message (verified by the `unknown error code` test).
+//
+// `application_passwords_unavailable` is intentionally *not* listed either —
+// its message is a `ReactNode` (it embeds an inline link to the WordPress
+// docs) rather than a plain string, so it has its own dedicated test below
+// that asserts on `errorCode` + structural properties of the message.
+const expectedErrorMessages: Array< {
+ code: string;
+ message: RegExp;
+} > = [
+ {
+ code: 'woocommerce_rest_cannot_view',
+ message: /do not have permission to generate a QR login code/i,
+ },
+ {
+ code: 'ssl_required',
+ message: /requires an HTTPS connection/i,
+ },
+ {
+ code: 'rate_limit_exceeded',
+ message: /requested QR login codes too quickly/i,
+ },
+];
+
+describe( 'useQRLoginToken', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ // Pin Date.now so the countdown math is deterministic.
+ jest.setSystemTime( NOW_SECONDS * 1000 );
+ jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
+ } );
+
+ afterEach( () => {
+ // Clear rather than run pending timers — otherwise the interval
+ // callback fires against (potentially unmounted) test hooks and
+ // React emits an "update not wrapped in act()" warning.
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ } );
+
+ it( 'starts IDLE with empty state', () => {
+ mockApiFetch.mockResolvedValue( buildResponse() );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.IDLE );
+ expect( result.current.qrUrl ).toBeNull();
+ expect( result.current.secondsRemaining ).toBe( 0 );
+ expect( result.current.errorMessage ).toBeNull();
+ expect( result.current.errorCode ).toBeNull();
+ } );
+
+ it( 'transitions IDLE → LOADING → READY on successful fetch', async () => {
+ const response = buildResponse();
+ mockApiFetch.mockResolvedValue( response );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.IDLE );
+
+ let fetchPromise: Promise< void > | undefined;
+ act( () => {
+ fetchPromise = result.current.fetchToken();
+ } );
+
+ // Synchronously after kicking off the fetch we should be LOADING.
+ expect( result.current.state ).toBe( QRLoginTokenStates.LOADING );
+
+ await act( async () => {
+ await fetchPromise;
+ } );
+
+ expect( mockApiFetch ).toHaveBeenCalledWith( {
+ path: '/wc-admin/mobile-app/qr-login-token',
+ method: 'POST',
+ } );
+ expect( mockApiFetch ).toHaveBeenCalledWith( {
+ path: '/wc-admin/mobile-app/qr-login-status',
+ method: 'POST',
+ data: { token: 'abc' },
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toBe( response.qr_url );
+ expect( result.current.errorMessage ).toBeNull();
+ expect( result.current.secondsRemaining ).toBe( TTL_SECONDS );
+ } );
+
+ it( 'decrements secondsRemaining each second and transitions to EXPIRED at 0', async () => {
+ mockApiFetch.mockResolvedValue( buildResponse( 3 ) );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.secondsRemaining ).toBe( 3 );
+
+ // `advanceTimersByTime` bumps the mocked clock *and* runs any
+ // interval callbacks whose due-time falls inside the advance
+ // window, so we don't need to call `setSystemTime` separately.
+ act( () => {
+ jest.advanceTimersByTime( 1000 );
+ } );
+ expect( result.current.secondsRemaining ).toBe( 2 );
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+
+ act( () => {
+ jest.advanceTimersByTime( 1000 );
+ } );
+ expect( result.current.secondsRemaining ).toBe( 1 );
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+
+ act( () => {
+ jest.advanceTimersByTime( 1000 );
+ } );
+ expect( result.current.secondsRemaining ).toBe( 0 );
+ expect( result.current.state ).toBe( QRLoginTokenStates.EXPIRED );
+ expect( result.current.qrUrl ).toBeNull();
+ } );
+
+ it.each( expectedErrorMessages )(
+ 'maps backend error code "$code" to the right user-facing message',
+ async ( { code, message } ) => {
+ mockApiFetch.mockRejectedValue( {
+ code,
+ message: `Backend said ${ code }`,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.qrUrl ).toBeNull();
+ expect( result.current.errorCode ).toBe( code );
+ expect( result.current.errorMessage ).toMatch( message );
+ }
+ );
+
+ it( 'surfaces the application_passwords_unavailable case with a ReactNode message + docs link', async () => {
+ mockApiFetch.mockRejectedValue( {
+ code: 'application_passwords_unavailable',
+ message: 'Backend said application_passwords_unavailable',
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorCode ).toBe(
+ 'application_passwords_unavailable'
+ );
+ // The message is a ReactNode (interpolated with an inline link), so
+ // we can't `toMatch` against a string. Just confirm it's set and not
+ // null — the rendering surface is `<QRDirectLoginCode />` and the
+ // link visibility is covered there.
+ expect( result.current.errorMessage ).not.toBeNull();
+ expect( typeof result.current.errorMessage ).not.toBe( 'string' );
+ } );
+
+ it( 'falls back to the backend-provided message for unknown error codes', async () => {
+ mockApiFetch.mockRejectedValue( {
+ code: 'something_unexpected',
+ message: 'Specific backend error text.',
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorMessage ).toBe(
+ 'Specific backend error text.'
+ );
+ } );
+
+ it( 'falls back to a generic message when the error has neither a code nor a message', async () => {
+ mockApiFetch.mockRejectedValue( {} );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorMessage ).toMatch(
+ /Failed to generate QR login code/i
+ );
+ } );
+
+ it( 'clears the previous error message when starting a new fetch', async () => {
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'ssl_required',
+ message: 'SSL required',
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorMessage ).not.toBeNull();
+
+ // Refetch with a successful response.
+ mockApiFetch.mockResolvedValueOnce( buildResponse() );
+
+ let retryPromise: Promise< void > | undefined;
+ act( () => {
+ retryPromise = result.current.refreshToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.LOADING );
+ expect( result.current.errorMessage ).toBeNull();
+ expect( result.current.errorCode ).toBeNull();
+
+ await act( async () => {
+ await retryPromise;
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ } );
+
+ it( 'refetch after EXPIRED yields a fresh token (EXPIRED → LOADING → READY)', async () => {
+ mockApiFetch.mockResolvedValueOnce( buildResponse( 1 ) );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ // Expire the current token.
+ act( () => {
+ jest.advanceTimersByTime( 1000 );
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.EXPIRED );
+
+ // Second fetch returns a new token. Build the response relative to
+ // the mocked "now" the hook will see at resolution time so the
+ // countdown math is unambiguous.
+ const freshNowSeconds = Math.floor( Date.now() / 1000 );
+ const secondResponse = {
+ qr_url: 'woocommerce://qr-login?token=second&siteUrl=x',
+ expires_at: freshNowSeconds + TTL_SECONDS,
+ ttl: TTL_SECONDS,
+ };
+ mockApiFetch.mockResolvedValueOnce( secondResponse );
+
+ let refetchPromise: Promise< void > | undefined;
+ act( () => {
+ refetchPromise = result.current.refreshToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.LOADING );
+
+ await act( async () => {
+ await refetchPromise;
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toBe( secondResponse.qr_url );
+ expect( result.current.secondsRemaining ).toBe( TTL_SECONDS );
+ // Count only the token-generation POSTs. The hook now also polls the
+ // status endpoint after each successful generate, so total apiFetch
+ // calls > 2 — but exactly two were token POSTs.
+ const tokenGenerateCalls = mockApiFetch.mock.calls.filter(
+ ( [ args ] ) =>
+ typeof args === 'object' &&
+ args !== null &&
+ 'path' in args &&
+ typeof ( args as { path: string } ).path === 'string' &&
+ ( args as { path: string } ).path.includes(
+ 'qr-login-token'
+ ) &&
+ ( args as { method?: string } ).method === 'POST'
+ );
+ expect( tokenGenerateCalls ).toHaveLength( 2 );
+ } );
+
+ // Cloudflare/VIP/etc. edge rate-limiters return an HTML 429 page;
+ // apiFetch surfaces that as `invalid_json` because the body isn't
+ // parseable. We collapse it onto the same merchant-facing message as
+ // our own rate-limit code so the user gets a clear next step instead
+ // of "The response is not a valid JSON response."
+ it( 'maps an upstream HTML 429 (apiFetch invalid_json) to the rate-limited message', async () => {
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'invalid_json',
+ message: 'The response is not a valid JSON response.',
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorMessage ).toMatch(
+ /requested QR login codes too quickly/i
+ );
+ // The raw "not a valid JSON response" message should never be
+ // surfaced to the merchant.
+ expect( result.current.errorMessage ).not.toMatch( /JSON/i );
+ } );
+
+ it( 'maps an explicit HTTP 429 status to the rate-limited message', async () => {
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'unexpected_code',
+ message: 'whatever',
+ data: { status: 429 },
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.errorMessage ).toMatch(
+ /requested QR login codes too quickly/i
+ );
+ } );
+
+ it( 'failed refetch clears the previous token and keeps the error visible', async () => {
+ const firstResponse = buildResponse( 5 );
+ mockApiFetch.mockResolvedValueOnce( firstResponse );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toBe( firstResponse.qr_url );
+ expect( result.current.secondsRemaining ).toBe( 5 );
+
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'rate_limit_exceeded',
+ message: 'Too many requests',
+ } );
+
+ let refetchPromise: Promise< void > | undefined;
+ act( () => {
+ refetchPromise = result.current.refreshToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.LOADING );
+ expect( result.current.qrUrl ).toBeNull();
+ expect( result.current.secondsRemaining ).toBe( 0 );
+
+ await act( async () => {
+ await refetchPromise;
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ expect( result.current.qrUrl ).toBeNull();
+ expect( result.current.secondsRemaining ).toBe( 0 );
+ expect( result.current.errorMessage ).toMatch(
+ /requested QR login codes too quickly/i
+ );
+
+ act( () => {
+ jest.advanceTimersByTime( 5000 );
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.ERROR );
+ } );
+
+ it( 'does not start a countdown after unmount during LOADING', async () => {
+ let resolveFetch: ( value: unknown ) => void = () => undefined;
+ const pendingResponse = new Promise( ( resolve ) => {
+ resolveFetch = resolve;
+ } );
+ mockApiFetch.mockReturnValueOnce( pendingResponse );
+ const setIntervalSpy = jest.spyOn( global, 'setInterval' );
+
+ const { result, unmount } = renderHook( () => useQRLoginToken() );
+
+ act( () => {
+ // Fire off the request but don't await it yet.
+ void result.current.fetchToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.LOADING );
+
+ // Unmount while still LOADING.
+ unmount();
+
+ // Now let the request resolve; the hook should not attempt to
+ // update state on the unmounted component.
+ await act( async () => {
+ resolveFetch( buildResponse() );
+ await pendingResponse;
+ } );
+
+ expect( setIntervalSpy ).not.toHaveBeenCalled();
+
+ setIntervalSpy.mockRestore();
+ } );
+
+ it( 'cleans up the countdown interval on unmount', async () => {
+ mockApiFetch.mockResolvedValue( buildResponse( 5 ) );
+ const clearIntervalSpy = jest.spyOn( global, 'clearInterval' );
+
+ const { result, unmount } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+
+ unmount();
+
+ expect( clearIntervalSpy ).toHaveBeenCalled();
+ clearIntervalSpy.mockRestore();
+ } );
+
+ it( 'ignores stale in-flight status polls after refreshing to a new token', async () => {
+ let resolveFirstStatusPoll: ( value: unknown ) => void = () =>
+ undefined;
+ const firstStatusPoll = new Promise( ( resolve ) => {
+ resolveFirstStatusPoll = resolve;
+ } );
+ let tokenRequestCount = 0;
+
+ mockApiFetch.mockImplementation( ( options ) => {
+ const request = options as {
+ path: string;
+ data?: { token?: string };
+ };
+
+ if ( request.path.includes( 'qr-login-token' ) ) {
+ ++tokenRequestCount;
+ return Promise.resolve(
+ tokenRequestCount === 1
+ ? buildResponse( TTL_SECONDS, 'first' )
+ : buildResponse( TTL_SECONDS, 'second' )
+ );
+ }
+
+ if ( request.data?.token === 'first' ) {
+ return firstStatusPoll;
+ }
+
+ return Promise.resolve( {
+ status: 'pending',
+ expires_at: NOW_SECONDS + TTL_SECONDS,
+ } );
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toContain( 'token=first' );
+
+ await act( async () => {
+ await result.current.refreshToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toContain( 'token=second' );
+
+ await act( async () => {
+ resolveFirstStatusPoll( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: { model: 'Stale Device' },
+ expires_at: NOW_SECONDS + 90,
+ } );
+ await firstStatusPoll;
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.READY );
+ expect( result.current.qrUrl ).toContain( 'token=second' );
+ expect( result.current.candidateNumbers ).toBeNull();
+ } );
+
+ // -----------------------------------------------------------------
+ // Task 7 — number-matching state transitions.
+ // -----------------------------------------------------------------
+
+ /**
+ * The status endpoint returns the new `scanned` shape once the mobile
+ * app has called /qr-login-scan. The hook should switch to the SCANNED
+ * state, surface the shuffled candidate triple + device info, and
+ * record the challenge expiry so the number-match step can render its
+ * own 90-s countdown.
+ */
+ it( 'transitions READY → SCANNED on a scanned status payload and surfaces the candidate triple', async () => {
+ // First call: token mint. Second call onward: status polls. The hook
+ // fires its first poll synchronously after the token mint resolves
+ // (no setInterval delay) so by the time the act() block flushes the
+ // fetchToken promise, the poll's microtask has already flipped state
+ // to SCANNED — that's the intentional UX (instant feedback).
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValue( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: {
+ os: 'Android',
+ os_version: '16',
+ model: 'Pixel 10',
+ app_version: '24.7.0',
+ },
+ expires_at: NOW_SECONDS + 90,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.SCANNED );
+ expect( result.current.candidateNumbers ).toEqual( [
+ '317',
+ '042',
+ '589',
+ ] );
+ expect( result.current.deviceInfo ).toMatchObject( {
+ model: 'Pixel 10',
+ os: 'Android',
+ } );
+ expect( result.current.challengeExpiresAt ).toBe( NOW_SECONDS + 90 );
+ // Once the scan is in we no longer render the QR — clear the
+ // plaintext-bearing qrUrl from React state so an XSS / malicious
+ // extension can't scrape it from the heap for the rest of the flow.
+ expect( result.current.qrUrl ).toBeNull();
+ } );
+
+ /**
+ * `chooseNumber` posts to /qr-login-approve. On `approved` the hook
+ * flips to APPROVED locally so the UI advances without waiting on the
+ * next status-poll tick. Tiles should be cleared so a re-render can't
+ * accidentally show stale candidates.
+ */
+ it( 'chooseNumber → approved flips state to APPROVED and clears candidates', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: { model: 'Pixel 10' },
+ expires_at: NOW_SECONDS + 90,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.SCANNED );
+
+ // Now stub the approve response.
+ mockApiFetch.mockResolvedValueOnce( { state: 'approved' } );
+
+ await act( async () => {
+ await result.current.chooseNumber( '042' );
+ } );
+
+ expect( mockApiFetch ).toHaveBeenLastCalledWith( {
+ path: '/wc-admin/mobile-app/qr-login-approve',
+ method: 'POST',
+ data: { token: 'abc', choice: '042' },
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+ expect( result.current.candidateNumbers ).toBeNull();
+ } );
+
+ it( 'ignores an older same-token scanned poll after chooseNumber approves', async () => {
+ let resolveStalePoll: ( value: unknown ) => void = () => undefined;
+ const stalePoll = new Promise( ( resolve ) => {
+ resolveStalePoll = resolve;
+ } );
+ let statusPollCount = 0;
+ const scannedResponse = {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: { model: 'Pixel 10' },
+ expires_at: NOW_SECONDS + 90,
+ };
+
+ mockApiFetch.mockImplementation( ( options ) => {
+ const request = options as { path: string };
+
+ if ( request.path.includes( 'qr-login-token' ) ) {
+ return Promise.resolve( buildResponse() );
+ }
+
+ if ( request.path.includes( 'qr-login-status' ) ) {
+ ++statusPollCount;
+ return statusPollCount === 1
+ ? Promise.resolve( scannedResponse )
+ : stalePoll;
+ }
+
+ return Promise.resolve( { state: 'approved' } );
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.SCANNED );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ await act( async () => {
+ await result.current.chooseNumber( '042' );
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+
+ await act( async () => {
+ resolveStalePoll( scannedResponse );
+ await stalePoll;
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+ expect( result.current.candidateNumbers ).toBeNull();
+ } );
+
+ it( 'ignores an older same-token scanned poll after a status poll approves', async () => {
+ let resolveStalePoll: ( value: unknown ) => void = () => undefined;
+ const stalePoll = new Promise( ( resolve ) => {
+ resolveStalePoll = resolve;
+ } );
+ const scannedResponse = {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: { model: 'Pixel 10' },
+ expires_at: NOW_SECONDS + 90,
+ };
+
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( scannedResponse )
+ .mockImplementationOnce( () => stalePoll )
+ .mockResolvedValueOnce( { status: 'approved' } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ expect( result.current.state ).toBe( QRLoginTokenStates.SCANNED );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+ expect( result.current.candidateNumbers ).toBeNull();
+
+ await act( async () => {
+ resolveStalePoll( scannedResponse );
+ await stalePoll;
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+ expect( result.current.candidateNumbers ).toBeNull();
+ expect( result.current.challengeExpiresAt ).toBe( 0 );
+ } );
+
+ it( 'moves APPROVED → EXPIRED when a later status poll reports expiry', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( { status: 'approved' } )
+ .mockResolvedValueOnce( { status: 'expired' } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.APPROVED );
+
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.EXPIRED );
+ expect( result.current.qrUrl ).toBeNull();
+ } );
+
+ /**
+ * Wrong pick → server returns `rejected`. The hook must terminate the
+ * session (clear the token ref so subsequent operations no-op) and
+ * surface the REJECTED state so the UI can render the terminal screen.
+ */
+ it( 'chooseNumber → rejected flips state to REJECTED and clears the token', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: {},
+ expires_at: NOW_SECONDS + 90,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ mockApiFetch.mockResolvedValueOnce( { state: 'rejected' } );
+
+ await act( async () => {
+ await result.current.chooseNumber( '317' );
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.REJECTED );
+ expect( result.current.candidateNumbers ).toBeNull();
+ } );
+
+ /**
+ * Approve responding 410 (challenge expired between scan and tap) is a
+ * race we want to surface as REJECTED so the user sees the same
+ * terminal "start over" screen rather than a misleading network error.
+ */
+ it( 'chooseNumber → 410 expired surfaces REJECTED', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: {},
+ expires_at: NOW_SECONDS + 90,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'qr_login_expired',
+ data: { status: 410 },
+ message: 'expired',
+ } );
+
+ await act( async () => {
+ await result.current.chooseNumber( '042' );
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.REJECTED );
+ } );
+
+ it( 'chooseNumber keeps the number-match step visible and surfaces non-terminal approval errors', async () => {
+ mockApiFetch
+ .mockResolvedValueOnce( buildResponse() )
+ .mockResolvedValueOnce( {
+ status: 'scanned',
+ numbers: [ '317', '042', '589' ],
+ device: {},
+ expires_at: NOW_SECONDS + 90,
+ } );
+
+ const { result } = renderHook( () => useQRLoginToken() );
+
+ await act( async () => {
+ await result.current.fetchToken();
+ } );
+ await act( async () => {
+ jest.advanceTimersByTime( 2600 );
+ await Promise.resolve();
+ } );
+
+ mockApiFetch.mockRejectedValueOnce( {
+ code: 'qr_login_approval_in_progress',
+ message: 'Approval is already in progress.',
+ data: { status: 409 },
+ } );
+
+ await act( async () => {
+ await result.current.chooseNumber( '042' );
+ } );
+
+ expect( result.current.state ).toBe( QRLoginTokenStates.SCANNED );
+ expect( result.current.errorCode ).toBe(
+ 'qr_login_approval_in_progress'
+ );
+ expect( result.current.errorMessage ).toBe(
+ 'Approval is already in progress.'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginAvailability.ts b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginAvailability.ts
new file mode 100644
index 00000000000..4926ac0a9d8
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginAvailability.ts
@@ -0,0 +1,87 @@
+/**
+ * External dependencies
+ */
+import { useState, useEffect } from '@wordpress/element';
+import { WC_ADMIN_NAMESPACE } from '@woocommerce/data';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * REST reason codes returned by `/qr-login-availability`. Mirrors the
+ * `MobileAppQRLogin::AVAILABILITY_REASON_*` constants on the server.
+ */
+export const QRLoginUnavailableReasons = {
+ HTTPS_REQUIRED: 'https_required',
+ APPLICATION_PASSWORDS_UNSUPPORTED: 'application_passwords_unsupported',
+ APPLICATION_PASSWORDS_DISABLED_BY_FILTER:
+ 'application_passwords_disabled_by_filter',
+} as const;
+
+export type QRLoginUnavailableReason =
+ ( typeof QRLoginUnavailableReasons )[ keyof typeof QRLoginUnavailableReasons ];
+
+type QRLoginAvailabilityResponse = {
+ available: boolean;
+ reason: QRLoginUnavailableReason | null;
+};
+
+/**
+ * Cheap up-front capability probe for the QR login feature. Hits
+ * `/qr-login-availability` once on mount so the QR card can be rendered
+ * permanently disabled (with the right explanation) instead of optimistically
+ * mounting and bouncing off `/qr-login-token`.
+ *
+ * The probe is intentionally idempotent and unrate-limited server-side, so
+ * remounting the modal is fine.
+ */
+export const useQRLoginAvailability = () => {
+ // `null` means "still probing"; `true`/`false` are the resolved states.
+ // Three states (not just `available: boolean`) so the UI can render an
+ // initial spinner instead of flashing a wrong state.
+ const [ available, setAvailable ] = useState< boolean | null >( null );
+ const [ reason, setReason ] = useState< QRLoginUnavailableReason | null >(
+ null
+ );
+
+ useEffect( () => {
+ let cancelled = false;
+
+ apiFetch< QRLoginAvailabilityResponse >( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-availability`,
+ method: 'GET',
+ } )
+ .then( ( response ) => {
+ if ( cancelled ) {
+ return;
+ }
+ if ( ! response || typeof response.available !== 'boolean' ) {
+ // Defensive: if the response shape is unexpected, treat
+ // the feature as available so the existing token-fetch
+ // path still runs and surfaces the real error.
+ setAvailable( true );
+ setReason( null );
+ return;
+ }
+ setAvailable( response.available );
+ setReason( response.reason ?? null );
+ } )
+ .catch( () => {
+ if ( cancelled ) {
+ return;
+ }
+ // Network / 5xx — fall through to the optimistic path so the
+ // existing error handling in <QRDirectLoginCode /> takes over.
+ setAvailable( true );
+ setReason( null );
+ } );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [] );
+
+ return {
+ isLoading: available === null,
+ available: available ?? false,
+ reason,
+ };
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginToken.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginToken.tsx
new file mode 100644
index 00000000000..392e78f0b95
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useQRLoginToken.tsx
@@ -0,0 +1,630 @@
+/**
+ * External dependencies
+ */
+import {
+ createInterpolateElement,
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+} from '@wordpress/element';
+import type { ReactNode } from 'react';
+import { __ } from '@wordpress/i18n';
+import { WC_ADMIN_NAMESPACE } from '@woocommerce/data';
+import apiFetch from '@wordpress/api-fetch';
+import { Link } from '@woocommerce/components';
+
+/**
+ * Documentation URL we link to when application passwords are unavailable.
+ * Centralized so the constant can be reused (e.g. in tests or future
+ * surfaces) and so the link is easy to update when the WP docs URL moves.
+ */
+const APPLICATION_PASSWORDS_DOCS_URL =
+ 'https://developer.wordpress.org/advanced-administration/security/application-passwords/';
+
+export const QRLoginTokenStates = {
+ IDLE: 'idle',
+ LOADING: 'loading',
+ READY: 'ready',
+ // Task 7 — number-matching states.
+ // SCANNED: mobile app scanned the QR; merchant must pick the right
+ // number from a shuffled triple to complete sign-in.
+ // APPROVED: merchant picked correctly; we're waiting on the mobile
+ // app to call /qr-login-exchange and finish the flow.
+ // REJECTED: terminal — wrong pick, or the merchant clicked
+ // "I don't recognise this device". No retry.
+ SCANNED: 'scanned',
+ APPROVED: 'approved',
+ REJECTED: 'rejected',
+ EXPIRED: 'expired',
+ CONSUMED: 'consumed',
+ REVOKED: 'revoked',
+ ERROR: 'error',
+} as const;
+
+export type QRLoginTokenState =
+ ( typeof QRLoginTokenStates )[ keyof typeof QRLoginTokenStates ];
+
+/**
+ * Whitelisted device-info shape returned by the status endpoint after a
+ * scan or successful exchange. The mobile app fills this in on the scan
+ * request, and the server reuses that stored payload for later status
+ * responses. Mirrors `MobileAppQRLogin::DEVICE_PAYLOAD_KEYS`.
+ *
+ * `brand` is Android-only (`Build.BRAND`); iOS clients leave it absent.
+ */
+export type QRLoginDeviceInfo = {
+ os?: string;
+ os_version?: string;
+ model?: string;
+ brand?: string;
+ app_version?: string;
+};
+
+type QRLoginTokenResponse = {
+ qr_url: string;
+ expires_at: number;
+ ttl: number;
+};
+
+type QRLoginStatusResponse =
+ | { status: 'pending'; expires_at: number }
+ | {
+ status: 'scanned';
+ numbers: [ string, string, string ];
+ device: QRLoginDeviceInfo;
+ expires_at: number;
+ }
+ | { status: 'approved' }
+ | { status: 'rejected' }
+ | {
+ status: 'consumed';
+ consumed_at: number;
+ ap_uuid: string;
+ ap_name: string | null;
+ device: QRLoginDeviceInfo;
+ }
+ | { status: 'expired' };
+
+/**
+ * Polling cadence for the status endpoint while the QR is on screen. ~2.5s
+ * gives the merchant near-instant feedback after scanning without hammering
+ * the backend; the per-user rate limit on the status endpoint allows ~24/min.
+ */
+const STATUS_POLL_INTERVAL_MS = 2500;
+
+type UseQRLoginTokenOptions = {
+ onReady?: () => void;
+ onError?: ( errorCode: string ) => void;
+};
+
+export const useQRLoginToken = ( {
+ onReady,
+ onError,
+}: UseQRLoginTokenOptions = {} ) => {
+ const [ state, setState ] = useState< QRLoginTokenState >(
+ QRLoginTokenStates.IDLE
+ );
+ const [ qrUrl, setQrUrl ] = useState< string | null >( null );
+ const [ secondsRemaining, setSecondsRemaining ] = useState< number >( 0 );
+ // `errorMessage` is rendered directly by `<QRDirectLoginCode />`. It is a
+ // `ReactNode` (not just `string`) so individual cases can inject inline
+ // links, for example the `application_passwords_unavailable` branch wraps a
+ // "Learn more" link in the message itself.
+ const [ errorMessage, setErrorMessage ] = useState< ReactNode | null >(
+ null
+ );
+ // `errorCode` mirrors the REST error code that triggered the message,
+ // exposed alongside `errorMessage` so callers (e.g. analytics) can
+ // reliably reference the failure mode regardless of how the message was
+ // rendered.
+ const [ errorCode, setErrorCode ] = useState< string | null >( null );
+ const [ deviceInfo, setDeviceInfo ] = useState< QRLoginDeviceInfo | null >(
+ null
+ );
+ const [ apUuid, setApUuid ] = useState< string | null >( null );
+ // Task 7 — shuffled candidate numbers surfaced to the merchant during
+ // the SCANNED state. The wc-admin client never sees which one is real;
+ // the server compares the merchant's choice against its own stored value.
+ const [ candidateNumbers, setCandidateNumbers ] = useState<
+ [ string, string, string ] | null
+ >( null );
+ const [ challengeExpiresAt, setChallengeExpiresAt ] =
+ useState< number >( 0 );
+ const timerRef = useRef< ReturnType< typeof setInterval > | null >( null );
+ // Plaintext token kept in a ref (not state) so it never causes re-renders
+ // and never leaves the hook closure. The status-polling and revoke calls
+ // need it server-side; the consumer only ever sees state transitions.
+ const tokenRef = useRef< string | null >( null );
+ const pollTimerRef = useRef< ReturnType< typeof setInterval > | null >(
+ null
+ );
+ const expiresAtRef = useRef< number >( 0 );
+ const onReadyRef = useRef( onReady );
+ const onErrorRef = useRef( onError );
+ const isMountedRef = useRef( true );
+ const requestIdRef = useRef( 0 );
+
+ onReadyRef.current = onReady;
+ onErrorRef.current = onError;
+
+ const clearTimer = useCallback( () => {
+ if ( timerRef.current ) {
+ clearInterval( timerRef.current );
+ timerRef.current = null;
+ }
+ }, [] );
+
+ const clearPollTimer = useCallback( () => {
+ if ( pollTimerRef.current ) {
+ clearInterval( pollTimerRef.current );
+ pollTimerRef.current = null;
+ }
+ }, [] );
+
+ const startCountdown = useCallback(
+ ( expiresAt: number ) => {
+ clearTimer();
+ expiresAtRef.current = expiresAt;
+
+ const updateRemaining = () => {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ const remaining = Math.max(
+ 0,
+ Math.floor( expiresAtRef.current - Date.now() / 1000 )
+ );
+ setSecondsRemaining( remaining );
+
+ if ( remaining <= 0 ) {
+ clearTimer();
+ clearPollTimer();
+ setState( QRLoginTokenStates.EXPIRED );
+ setQrUrl( null );
+ tokenRef.current = null;
+ }
+ };
+
+ updateRemaining();
+ timerRef.current = setInterval( updateRemaining, 1000 );
+ },
+ [ clearTimer, clearPollTimer ]
+ );
+
+ /**
+ * Extract the plaintext token from the deep link returned by
+ * `qr-login-token`. Robust against future query-string ordering changes.
+ * Returns `null` if the URL doesn't carry a token (defensive).
+ */
+ const extractTokenFromQrUrl = ( deepLink: string ): string | null => {
+ const queryStart = deepLink.indexOf( '?' );
+ if ( queryStart === -1 ) {
+ return null;
+ }
+ const params = new URLSearchParams( deepLink.slice( queryStart + 1 ) );
+ const token = params.get( 'token' );
+ return token ? token : null;
+ };
+
+ /**
+ * Poll the status endpoint while the QR is on screen, stop as soon as the
+ * server reports the token has been consumed (mobile app exchanged it for
+ * an Application Password) so the UI can transition to the confirmation
+ * panel within a couple of seconds of the user scanning. Called from the
+ * effect below when state flips to READY and a plaintext token is in scope.
+ */
+ const pollStatus = useCallback( async () => {
+ const token = tokenRef.current;
+ if ( ! token || ! isMountedRef.current ) {
+ return;
+ }
+ const requestId = requestIdRef.current;
+
+ try {
+ const response = await apiFetch< QRLoginStatusResponse >( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-status`,
+ method: 'POST',
+ data: { token },
+ } );
+
+ if (
+ ! isMountedRef.current ||
+ token !== tokenRef.current ||
+ requestId !== requestIdRef.current
+ ) {
+ return;
+ }
+
+ if ( response.status === 'consumed' ) {
+ clearTimer();
+ clearPollTimer();
+ setQrUrl( null );
+ setApUuid( response.ap_uuid );
+ setDeviceInfo( response.device || null );
+ setState( QRLoginTokenStates.CONSUMED );
+ tokenRef.current = null;
+ return;
+ }
+
+ if ( response.status === 'scanned' ) {
+ // Mobile app scanned the QR. Surface the shuffled candidate
+ // triple so the merchant can confirm by tapping the matching
+ // number. The countdown timer is replaced by the
+ // challenge-expires-at window (90s after scan) so the user
+ // gets a clear sense of urgency for the pick.
+ clearTimer();
+ // Drop the plaintext token from React state once the scan is
+ // in. The QR view is no longer rendered (the component swaps
+ // to the number-match step), polling reads the token from
+ // `tokenRef`, and clearing the visible state limits what an
+ // XSS or malicious browser extension can scrape from the JS
+ // heap for the rest of the flow.
+ setQrUrl( null );
+ setCandidateNumbers( response.numbers );
+ setChallengeExpiresAt( response.expires_at );
+ setDeviceInfo( response.device || null );
+ setState( QRLoginTokenStates.SCANNED );
+ startCountdown( response.expires_at );
+ return;
+ }
+
+ if ( response.status === 'approved' ) {
+ // Merchant tapped correctly on this tab or another tab. Show
+ // "Signing in…" until the mobile app finishes the exchange and
+ // the next poll flips us to CONSUMED.
+ requestIdRef.current += 1;
+ clearTimer();
+ setCandidateNumbers( null );
+ setChallengeExpiresAt( 0 );
+ setState( QRLoginTokenStates.APPROVED );
+ return;
+ }
+
+ if ( response.status === 'rejected' ) {
+ clearTimer();
+ clearPollTimer();
+ setQrUrl( null );
+ setCandidateNumbers( null );
+ setState( QRLoginTokenStates.REJECTED );
+ tokenRef.current = null;
+ return;
+ }
+
+ if ( response.status === 'expired' ) {
+ clearTimer();
+ clearPollTimer();
+ setQrUrl( null );
+ setCandidateNumbers( null );
+ setChallengeExpiresAt( 0 );
+ setState( QRLoginTokenStates.EXPIRED );
+ tokenRef.current = null;
+ }
+ // `pending` is a no-op here — the countdown timer drives the
+ // normal READY expiry transition; we just keep polling.
+ } catch ( error ) {
+ // Swallow polling errors. A transient 500/429 should not break the
+ // QR flow — the next tick will retry, and the countdown will
+ // eventually push us to EXPIRED.
+ // eslint-disable-next-line no-console
+ console.warn( 'QR login status polling failed.', error );
+ }
+ }, [ clearTimer, clearPollTimer, startCountdown ] );
+
+ const startStatusPolling = useCallback( () => {
+ clearPollTimer();
+ // First tick fires immediately so a fast-scanning merchant gets the
+ // confirmation panel without waiting a full interval.
+ pollStatus();
+ pollTimerRef.current = setInterval(
+ pollStatus,
+ STATUS_POLL_INTERVAL_MS
+ );
+ }, [ clearPollTimer, pollStatus ] );
+
+ const fetchToken = useCallback( async () => {
+ const requestId = requestIdRef.current + 1;
+ requestIdRef.current = requestId;
+
+ clearTimer();
+ clearPollTimer();
+ tokenRef.current = null;
+ setApUuid( null );
+ setDeviceInfo( null );
+ setCandidateNumbers( null );
+ setChallengeExpiresAt( 0 );
+ expiresAtRef.current = 0;
+ setQrUrl( null );
+ setSecondsRemaining( 0 );
+ setState( QRLoginTokenStates.LOADING );
+ setErrorMessage( null );
+ setErrorCode( null );
+
+ try {
+ const response = await apiFetch< QRLoginTokenResponse >( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-token`,
+ method: 'POST',
+ } );
+
+ if (
+ ! isMountedRef.current ||
+ requestId !== requestIdRef.current
+ ) {
+ return;
+ }
+
+ if (
+ ! response ||
+ typeof response.qr_url !== 'string' ||
+ response.qr_url.length === 0 ||
+ ! Number.isFinite( response.expires_at ) ||
+ response.expires_at <= Date.now() / 1000
+ ) {
+ throw new Error(
+ __(
+ 'Failed to generate QR login code. Please try again.',
+ 'woocommerce'
+ )
+ );
+ }
+
+ tokenRef.current = extractTokenFromQrUrl( response.qr_url );
+ setQrUrl( response.qr_url );
+ setState( QRLoginTokenStates.READY );
+ startCountdown( response.expires_at );
+ onReadyRef.current?.();
+ // Kick off the poll loop only once we actually have a token to
+ // poll for. If the URL was malformed and we couldn't extract one,
+ // the QR still renders, we just won't transition to CONSUMED
+ // until the next refresh.
+ if ( tokenRef.current ) {
+ startStatusPolling();
+ }
+ } catch ( error: unknown ) {
+ if (
+ ! isMountedRef.current ||
+ requestId !== requestIdRef.current
+ ) {
+ return;
+ }
+
+ clearTimer();
+ clearPollTimer();
+ tokenRef.current = null;
+ expiresAtRef.current = 0;
+ setQrUrl( null );
+ setSecondsRemaining( 0 );
+
+ const err = error as {
+ code?: string;
+ message?: string;
+ data?: { status?: number };
+ };
+ const nextErrorCode = err.code ?? null;
+ let nextErrorMessage: ReactNode;
+
+ // Edge rate-limiters (Cloudflare, VIP, etc.) return an HTML 429
+ // page, and apiFetch surfaces that as `invalid_json`. Treat our
+ // own code, an explicit HTTP 429, and that parse failure as the
+ // same merchant-facing "wait a moment" state.
+ const httpStatus = err.data?.status;
+ const isRateLimited =
+ nextErrorCode === 'rate_limit_exceeded' ||
+ nextErrorCode === 'invalid_json' ||
+ httpStatus === 429;
+
+ if ( isRateLimited ) {
+ nextErrorMessage = __(
+ "You've requested QR login codes too quickly. Please wait a moment and try again.",
+ 'woocommerce'
+ );
+ } else {
+ switch ( nextErrorCode ) {
+ case 'woocommerce_rest_cannot_view':
+ // The endpoint requires the `manage_woocommerce`
+ // capability; surface a clear, actionable message
+ // rather than the generic REST wording.
+ nextErrorMessage = __(
+ 'You do not have permission to generate a QR login code. Ask a site administrator for help.',
+ 'woocommerce'
+ );
+ break;
+ case 'ssl_required':
+ nextErrorMessage = __(
+ 'QR login requires an HTTPS connection.',
+ 'woocommerce'
+ );
+ break;
+ case 'application_passwords_unavailable':
+ nextErrorMessage = createInterpolateElement(
+ __(
+ 'Application passwords are disabled on this site, so QR login is unavailable. Find more about application passwords <link>here</link>.',
+ 'woocommerce'
+ ),
+ {
+ link: (
+ <Link
+ href={ APPLICATION_PASSWORDS_DOCS_URL }
+ target="_blank"
+ type="external"
+ />
+ ),
+ }
+ );
+ break;
+ default:
+ nextErrorMessage =
+ err.message ||
+ __(
+ 'Failed to generate QR login code. Please try again.',
+ 'woocommerce'
+ );
+ }
+ }
+
+ setErrorCode( nextErrorCode );
+ setErrorMessage( nextErrorMessage );
+ setState( QRLoginTokenStates.ERROR );
+ onErrorRef.current?.( nextErrorCode ?? 'unknown_error' );
+ }
+ }, [ clearTimer, clearPollTimer, startCountdown, startStatusPolling ] );
+
+ /**
+ * Task 7 — Submit the merchant's number-match choice to /qr-login-approve.
+ *
+ * One-strike. The server treats any non-matching choice as a terminal
+ * REJECTED, with no retry. We optimistically flip the local state to
+ * APPROVED on a successful approve response so the UI updates without
+ * waiting for the next poll tick — the next poll will confirm CONSUMED
+ * once the mobile app finishes the exchange.
+ *
+ * @param choice The 3-digit string the merchant tapped, or any sentinel
+ * (e.g. empty string from the "It wasn't me" cancel link)
+ * for an explicit user-initiated rejection.
+ */
+ const chooseNumber = useCallback(
+ async ( choice: string ) => {
+ const token = tokenRef.current;
+ if ( ! token ) {
+ return;
+ }
+
+ setErrorMessage( null );
+ setErrorCode( null );
+
+ try {
+ const response = await apiFetch< {
+ state: 'approved' | 'rejected';
+ } >( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-approve`,
+ method: 'POST',
+ data: { token, choice },
+ } );
+
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ if ( response.state === 'approved' ) {
+ requestIdRef.current += 1;
+ clearTimer();
+ setCandidateNumbers( null );
+ setChallengeExpiresAt( 0 );
+ setState( QRLoginTokenStates.APPROVED );
+ } else {
+ clearTimer();
+ clearPollTimer();
+ setCandidateNumbers( null );
+ setChallengeExpiresAt( 0 );
+ tokenRef.current = null;
+ setState( QRLoginTokenStates.REJECTED );
+ }
+ } catch ( error: unknown ) {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ const err = error as {
+ code?: string;
+ message?: string;
+ data?: { status?: number };
+ };
+
+ // 410 → challenge expired between scan and tap. Surface as
+ // REJECTED so the user sees the same "Login denied — start
+ // over" terminal screen rather than a misleading network error.
+ if (
+ err.code === 'qr_login_expired' ||
+ err.data?.status === 410
+ ) {
+ clearTimer();
+ clearPollTimer();
+ setCandidateNumbers( null );
+ tokenRef.current = null;
+ setState( QRLoginTokenStates.REJECTED );
+ return;
+ }
+
+ setErrorMessage(
+ err.message ||
+ __(
+ 'Failed to confirm sign-in. Please try generating a new code.',
+ 'woocommerce'
+ )
+ );
+ setErrorCode( err.code ?? null );
+ }
+ },
+ [ clearTimer, clearPollTimer ]
+ );
+
+ /**
+ * Revoke the Application Password issued by the most recent successful
+ * exchange. Only meaningful when state === CONSUMED — earlier states
+ * have no AP yet, REVOKED is a no-op, and EXPIRED means the consumed
+ * record may already be gone from the server.
+ */
+ const revoke = useCallback( async () => {
+ if ( ! apUuid ) {
+ return;
+ }
+
+ try {
+ await apiFetch( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-revoke`,
+ method: 'DELETE',
+ data: { uuid: apUuid },
+ } );
+
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ setState( QRLoginTokenStates.REVOKED );
+ } catch ( error: unknown ) {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ const err = error as { message?: string };
+ setErrorMessage(
+ err.message ||
+ __(
+ 'Failed to revoke access. Please try again or remove the application password manually under Users → Profile.',
+ 'woocommerce'
+ )
+ );
+ }
+ }, [ apUuid ] );
+
+ // Cleanup timers + polling on unmount.
+ useEffect( () => {
+ isMountedRef.current = true;
+
+ return () => {
+ isMountedRef.current = false;
+ requestIdRef.current += 1;
+ tokenRef.current = null;
+ clearTimer();
+ clearPollTimer();
+ };
+ }, [ clearTimer, clearPollTimer ] );
+
+ return {
+ state,
+ qrUrl,
+ secondsRemaining,
+ errorMessage,
+ errorCode,
+ deviceInfo,
+ apUuid,
+ // Task 7.
+ candidateNumbers,
+ challengeExpiresAt,
+ chooseNumber,
+ fetchToken,
+ refreshToken: fetchToken,
+ revoke,
+ };
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useRevokeQRLoginAccess.ts b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useRevokeQRLoginAccess.ts
new file mode 100644
index 00000000000..a6e57a0deea
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useRevokeQRLoginAccess.ts
@@ -0,0 +1,104 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { WC_ADMIN_NAMESPACE } from '@woocommerce/data';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Standalone hook that wraps the `DELETE /wc-admin/mobile-app/qr-login-revoke`
+ * call.
+ *
+ * Owned separately from `useQRLoginToken` because the third stepper step
+ * outlives the QR component — once we advance to "Signed in successfully" the
+ * QR is unmounted, but we still need to revoke the Application Password
+ * issued by the most recent exchange. Keeping the revoke logic in its own
+ * hook lets the success step manage its own request lifecycle without
+ * threading state up from the unmounted QR.
+ */
+export const useRevokeQRLoginAccess = () => {
+ const [ isRevoking, setIsRevoking ] = useState< boolean >( false );
+ const [ isRevoked, setIsRevoked ] = useState< boolean >( false );
+ const [ errorMessage, setErrorMessage ] = useState< string | null >( null );
+ const isMountedRef = useRef( true );
+
+ useEffect( () => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [] );
+
+ const revoke = useCallback( async ( apUuid: string ) => {
+ if ( ! apUuid ) {
+ return;
+ }
+
+ setIsRevoking( true );
+ setErrorMessage( null );
+
+ try {
+ await apiFetch( {
+ path: `${ WC_ADMIN_NAMESPACE }/mobile-app/qr-login-revoke`,
+ method: 'DELETE',
+ data: { uuid: apUuid },
+ } );
+
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ setIsRevoked( true );
+ } catch ( error: unknown ) {
+ if ( ! isMountedRef.current ) {
+ return;
+ }
+
+ const err = error as {
+ code?: string;
+ message?: string;
+ data?: { status?: number };
+ };
+
+ // Same rate-limit detection as useQRLoginToken: our own
+ // `rate_limit_exceeded`, the upstream edge limiter's HTML 429
+ // (apiFetch reports as `invalid_json`), or any explicit 429
+ // status. All three resolve to the same merchant-facing message.
+ const httpStatus = err.data?.status;
+ const isRateLimited =
+ err.code === 'rate_limit_exceeded' ||
+ err.code === 'invalid_json' ||
+ httpStatus === 429;
+
+ if ( isRateLimited ) {
+ setErrorMessage(
+ __(
+ "You're sending requests too quickly. Please wait a moment and try again.",
+ 'woocommerce'
+ )
+ );
+ return;
+ }
+
+ setErrorMessage(
+ err.message ||
+ __(
+ 'Failed to revoke access. Please try again or remove the application password manually under Users → Profile.',
+ 'woocommerce'
+ )
+ );
+ } finally {
+ if ( isMountedRef.current ) {
+ setIsRevoking( false );
+ }
+ }
+ }, [] );
+
+ return {
+ revoke,
+ isRevoking,
+ isRevoked,
+ errorMessage,
+ };
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useSendMagicLink.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useSendMagicLink.tsx
index fa4fc7e276e..66c4067410b 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useSendMagicLink.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/components/useSendMagicLink.tsx
@@ -57,14 +57,27 @@ export const useSendMagicLink = () => {
error: response.message,
code: response.code,
} );
+ // Log the full response so the actual Jetpack / backend error
+ // is visible in the browser console for debugging. The
+ // user-facing notice is intentionally friendlier below.
+ // eslint-disable-next-line no-console
+ console.error( 'Send magic link failed:', response );
+
+ const genericRetry = __(
+ 'We couldn’t send the link. Try again in a few seconds.',
+ 'woocommerce'
+ );
+
if ( response.code === 'error_sending_mobile_magic_link' ) {
- createNotice(
- 'error',
- __(
- 'We couldn’t send the link. Try again in a few seconds.',
- 'woocommerce'
- )
- );
+ // The backend embeds the Jetpack error as
+ // "<code>: <message>" in response.message. Surface it
+ // alongside the retry guidance so merchants and support
+ // can distinguish a transient hiccup from a real
+ // configuration problem.
+ const detail = response.message
+ ? ` (${ response.message })`
+ : '';
+ createNotice( 'error', `${ genericRetry }${ detail }` );
} else if (
response.code === 'invalid_user_permission_view_admin'
) {
@@ -78,10 +91,7 @@ export const useSendMagicLink = () => {
} else if ( response.code === 'jetpack_not_connected' ) {
createNotice( 'error', response.message );
} else {
- createNotice(
- 'error',
- 'We couldn’t send the link. Try again in a few seconds.'
- );
+ createNotice( 'error', genericRetry );
}
} );
}, [ createNotice ] );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/pages/MobileAppLoginStepperPage.tsx b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/pages/MobileAppLoginStepperPage.tsx
index f154915ebb6..da8d09c31e6 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/pages/MobileAppLoginStepperPage.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/pages/MobileAppLoginStepperPage.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import { useCallback, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
@@ -9,6 +10,7 @@ import { __ } from '@wordpress/i18n';
import { ModalContentLayoutWithTitle } from '../layouts/ModalContentLayoutWithTitle';
import { SendMagicLinkStates } from '../components';
import { MobileAppLoginStepper } from '../components/MobileAppLoginStepper';
+import type { QRLoginConsumedSnapshot } from '../components/QRDirectLoginCode';
interface MobileAppLoginStepperPageProps {
appInstalledClicked: boolean;
@@ -26,23 +28,53 @@ export const MobileAppLoginStepperPage = ( {
completeInstallationHandler,
sendMagicLinkHandler,
sendMagicLinkStatus,
-}: MobileAppLoginStepperPageProps ) => (
- <ModalContentLayoutWithTitle>
- <div className="modal-subheader">
- <h3>
- { __(
- 'Run your store from anywhere in two easy steps.',
- 'woocommerce'
- ) }
- </h3>
- </div>
- <MobileAppLoginStepper
- step={ appInstalledClicked ? 'second' : 'first' }
- isJetpackPluginInstalled={ isJetpackPluginInstalled }
- wordpressAccountEmailAddress={ wordpressAccountEmailAddress }
- completeInstallationStepHandler={ completeInstallationHandler }
- sendMagicLinkHandler={ sendMagicLinkHandler }
- sendMagicLinkStatus={ sendMagicLinkStatus }
- />
- </ModalContentLayoutWithTitle>
-);
+}: MobileAppLoginStepperPageProps ) => {
+ // Captured the moment the QR component reports a successful exchange.
+ // `signInResult` doubles as the trigger for advancing to step 3 — the
+ // stepper renders the new success step iff this is non-null.
+ const [ signInResult, setSignInResult ] =
+ useState< QRLoginConsumedSnapshot | null >( null );
+
+ const handleSignedIn = useCallback(
+ ( snapshot: QRLoginConsumedSnapshot ) => {
+ // Only set on the first transition. The QR component's effect
+ // fires on every render while in the consumed state; ignoring
+ // subsequent calls keeps the success step from re-rendering with
+ // the same data.
+ setSignInResult( ( prev ) => prev ?? snapshot );
+ },
+ []
+ );
+
+ let step: 'first' | 'second' | 'third';
+ if ( signInResult ) {
+ step = 'third';
+ } else if ( appInstalledClicked ) {
+ step = 'second';
+ } else {
+ step = 'first';
+ }
+
+ return (
+ <ModalContentLayoutWithTitle>
+ <div className="modal-subheader">
+ <h3>
+ { __(
+ 'Run your store from anywhere with the Woo mobile app.',
+ 'woocommerce'
+ ) }
+ </h3>
+ </div>
+ <MobileAppLoginStepper
+ step={ step }
+ isJetpackPluginInstalled={ isJetpackPluginInstalled }
+ wordpressAccountEmailAddress={ wordpressAccountEmailAddress }
+ signInResult={ signInResult }
+ completeInstallationStepHandler={ completeInstallationHandler }
+ sendMagicLinkHandler={ sendMagicLinkHandler }
+ sendMagicLinkStatus={ sendMagicLinkStatus }
+ onSignedIn={ handleSignedIn }
+ />
+ </ModalContentLayoutWithTitle>
+ );
+};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/style.scss b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/style.scss
index bfb2c8d8c47..10fb4c1d081 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/style.scss
+++ b/plugins/woocommerce/client/admin/client/homescreen/mobile-app-modal/style.scss
@@ -206,13 +206,10 @@
}
button.components-button.send-magic-link-button {
- background: var(--wp-admin-theme-color, #007cba);
+ background: #007cba;
color: #fff;
position: relative;
margin-bottom: 1em;
- &:hover {
- background: var(--wp-admin-theme-color-darker-20, #005a87);
- }
:hover {
// the hover state makes the text invisible so we need to override it
color: #fff;
@@ -241,6 +238,102 @@ button.components-button.send-magic-link-button {
}
}
+// Shared `<QRDirectLoginCode />` rules live in the partial below. They are
+// reused by the standalone wc-admin page (`client/mobile-app-login`) so both
+// surfaces render the same QR / number-match / approved / rejected layouts.
+@import "./components/qr-direct-login.scss";
+
+// FAQ paragraph that sits below the stepper / QR block. Adds the breathing
+// room and the thin grey divider so the link doesn't visually collide with
+// the content above it.
+.mobile-app-login-faq {
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid #f0f0f0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: $gray-700;
+ text-align: left;
+
+ a {
+ color: #007cba;
+ }
+}
+
+// Step 3 — sign-in success panel.
+// Mirrors the rest of the modal: 24px / 16px vertical rhythm and WP base
+// palette text colours.
+.qr-login-success-step {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-top: 8px;
+
+ // "Heading" + "description" are reused only by the post-revoke "Access
+ // revoked" panel; the inline success copy was dropped because the
+ // stepper label already conveys success.
+ .qr-login-success-step__heading {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.5;
+ margin: 0 0 4px;
+ color: $gray-900;
+ }
+
+ .qr-login-success-step__description {
+ font-size: 14px;
+ line-height: 1.5;
+ color: $gray-700;
+ margin: 0;
+ max-width: 32em;
+ }
+
+ // Single-line device summary (device · app version) shown above the
+ // revoke prompt — mirrors the standalone QRLoginConsumedPanel subline so
+ // the merchant can confirm which device signed in.
+ .qr-login-success-step__device-details {
+ font-size: 13px;
+ line-height: 1.5;
+ color: $gray-700;
+ margin: 0 0 12px;
+ max-width: 32em;
+ }
+
+ // "It wasn't you?" prompt + "Revoke access" CTA on the same row to make
+ // better use of the horizontal space the stepper layout already gives us.
+ .qr-login-success-step__revoke-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ .qr-login-success-step__challenge {
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.5;
+ color: $gray-900;
+ margin: 0;
+ }
+
+ .qr-login-success-step__error {
+ margin: 12px 0 0;
+ color: $alert-red;
+ font-size: 13px;
+ line-height: 1.4;
+ }
+}
+
+// Confirmation modal — keep the action row right-aligned (Cancel | Revoke).
+.components-modal__frame.qr-login-success-step__confirm-modal {
+ .qr-login-success-step__confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 16px;
+ }
+}
+
.email-sent-modal-body {
padding-block: 40px;
padding-inline: 56px;
@@ -322,4 +415,3 @@ button.components-button.send-magic-link-button {
}
}
}
-
diff --git a/plugins/woocommerce/client/admin/client/layout/controller.js b/plugins/woocommerce/client/admin/client/layout/controller.js
index 5018838d0f8..f06a81fb499 100644
--- a/plugins/woocommerce/client/admin/client/layout/controller.js
+++ b/plugins/woocommerce/client/admin/client/layout/controller.js
@@ -76,6 +76,10 @@ const LaunchStore = lazy( () =>
import( /* webpackChunkName: "launch-store" */ '../launch-your-store/hub' )
);
+const MobileAppLoginPage = lazy( () =>
+ import( /* webpackChunkName: "mobile-app-login" */ '../mobile-app-login' )
+);
+
export const PAGES_FILTER = 'woocommerce_admin_pages_list';
export const getPages = ( reports = [] ) => {
@@ -329,6 +333,20 @@ export const getPages = ( reports = [] ) => {
} );
}
+ pages.push( {
+ container: MobileAppLoginPage,
+ path: '/mobile-app-login',
+ breadcrumbs: [
+ ...initialBreadcrumbs,
+ __( 'Mobile app login', 'woocommerce' ),
+ ],
+ wpOpenMenu: 'toplevel_page_woocommerce',
+ navArgs: {
+ id: 'woocommerce-mobile-app-login',
+ },
+ capability: 'manage_woocommerce',
+ } );
+
/**
* List of WooCommerce Admin pages.
*
diff --git a/plugins/woocommerce/client/admin/client/mobile-app-login/README.md b/plugins/woocommerce/client/admin/client/mobile-app-login/README.md
new file mode 100644
index 00000000000..06e655b141e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/mobile-app-login/README.md
@@ -0,0 +1,51 @@
+# Mobile app login
+
+Standalone wc-admin page that lets a logged-in merchant sign in to the Woo
+mobile app on their phone by scanning a QR code.
+
+## Audience
+
+Merchants who already have the Woo mobile app installed on their phone and
+want a direct, one-shot way to sign in — without going through the onboarding
+modal on the wc-admin home screen.
+
+## Route
+
+`/wp-admin/admin.php?page=wc-admin&path=/mobile-app-login`
+
+Registered in `client/admin/client/layout/controller.js` with capability
+`manage_woocommerce`, so admins and shop managers can reach it.
+
+## Scope
+
+Application Password flow only. The WordPress.com multi-store flow is deferred
+to a future task. The page's single-column layout leaves room below the QR for
+a future secondary CTA, so adding that flow later does not require a
+structural rewrite.
+
+## Reused components
+
+- `<QRDirectLoginCode />` from
+ `client/admin/client/homescreen/mobile-app-modal/components/QRDirectLoginCode.tsx`
+ renders the QR, countdown, and in-QR FAQ link.
+- `useQRLoginToken` from
+ `client/admin/client/homescreen/mobile-app-modal/components/useQRLoginToken.tsx`
+ owns the token lifecycle — it is consumed indirectly by
+ `<QRDirectLoginCode />`.
+
+Neither file is modified by this page. The shared QR component only allows a
+manual retry after it reaches an error or expired state, so the page does not
+mint parallel valid login tokens while a QR code is still live.
+
+## What this page does not do
+
+- No Jetpack / WordPress.com detection branching.
+- No `<SendMagicLinkButton />` — the email magic-link CTA stays in the
+ onboarding modal only.
+- No URL input field — the page is only reachable inside the merchant's own
+ wp-admin, so the site URL is already known.
+- No "install the app" wizard step — the audience is "app already installed."
+
+## Tests
+
+See `test/index.test.tsx`.
diff --git a/plugins/woocommerce/client/admin/client/mobile-app-login/index.tsx b/plugins/woocommerce/client/admin/client/mobile-app-login/index.tsx
new file mode 100644
index 00000000000..e992c8a6c2a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/mobile-app-login/index.tsx
@@ -0,0 +1,109 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Card, CardBody } from '@wordpress/components';
+import { useEffect } from '@wordpress/element';
+import interpolateComponents from '@automattic/interpolate-components';
+import { Link } from '@woocommerce/components';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import { QRDirectLoginCode } from '~/homescreen/mobile-app-modal/components/QRDirectLoginCode';
+import WooLogo from '~/core-profiler/components/navigation/woologo';
+import './style.scss';
+
+/**
+ * URL to the mobile app login help article.
+ *
+ * Kept in sync with the FAQ link rendered inside `<QRDirectLoginCode />` —
+ * both point at the same canonical help page so merchants land in the same
+ * place regardless of which troubleshooting affordance they tap.
+ */
+const FAQ_URL =
+ 'https://woocommerce.com/document/android-ios-apps-login-help-faq/';
+
+/**
+ * Standalone wc-admin page for signing in to the Woo mobile app via QR code.
+ *
+ * Audience: merchants who already have the Woo mobile app installed on their
+ * phone and want a quick, one-shot way to sign in without going through the
+ * onboarding modal.
+ *
+ * This page only builds the Application Password flow. The WordPress.com
+ * multi-store flow is intentionally deferred — the single-column layout
+ * leaves room below the QR for a future secondary CTA without needing a
+ * structural rewrite.
+ */
+export const MobileAppLoginPage = () => {
+ useEffect( () => {
+ recordEvent( 'mobile_app_qr_login_page_viewed' );
+ }, [] );
+
+ return (
+ <div className="woocommerce-mobile-app-login">
+ <Card className="woocommerce-mobile-app-login__card">
+ <CardBody className="woocommerce-mobile-app-login__body">
+ <div
+ className="woocommerce-mobile-app-login__logo"
+ aria-hidden="true"
+ >
+ <WooLogo />
+ </div>
+ <h1 className="woocommerce-mobile-app-login__heading">
+ { __( 'Sign in to the Woo mobile app', 'woocommerce' ) }
+ </h1>
+ <p className="woocommerce-mobile-app-login__intro">
+ { interpolateComponents( {
+ mixedString: __(
+ 'Open the Woo mobile app on your phone, tap {{strong}}Scan QR code{{/strong}}, then point your camera at the code below.',
+ 'woocommerce'
+ ),
+ components: {
+ strong: <strong />,
+ },
+ } ) }
+ </p>
+
+ <div className="woocommerce-mobile-app-login__qr">
+ <QRDirectLoginCode />
+ </div>
+
+ { /*
+ * Leave room below the actions for a future "Log in with
+ * WordPress.com for multi-store access" secondary CTA
+ * (see WOOMOB-2767 plan). Do not add the UI here — a
+ * separate task owns that flow.
+ */ }
+
+ <p className="woocommerce-mobile-app-login__faq">
+ { interpolateComponents( {
+ mixedString: __(
+ 'Any troubles signing in? Check out the {{link}}FAQ{{/link}}.',
+ 'woocommerce'
+ ),
+ components: {
+ link: (
+ <Link
+ href={ FAQ_URL }
+ target="_blank"
+ type="external"
+ onClick={ () => {
+ recordEvent(
+ 'mobile_app_qr_login_page_faq_click'
+ );
+ } }
+ />
+ ),
+ },
+ } ) }
+ </p>
+ </CardBody>
+ </Card>
+ </div>
+ );
+};
+
+export default MobileAppLoginPage;
diff --git a/plugins/woocommerce/client/admin/client/mobile-app-login/style.scss b/plugins/woocommerce/client/admin/client/mobile-app-login/style.scss
new file mode 100644
index 00000000000..f202e741b34
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/mobile-app-login/style.scss
@@ -0,0 +1,96 @@
+// Shared QR-direct-login layout rules. Imported from the modal package so
+// the standalone page renders the QR + number-match / approved / rejected
+// states identically to the modal.
+@import "../homescreen/mobile-app-modal/components/qr-direct-login.scss";
+
+.woocommerce-mobile-app-login {
+ display: flex;
+ justify-content: center;
+ padding: $gap-large;
+
+ &__card {
+ width: 100%;
+ max-width: 560px;
+ border-top: 4px solid $studio-woocommerce-purple-50;
+ overflow: hidden;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: $gap-largest;
+ }
+
+ // Real Woo wordmark at the top of the card. Matches the 91×24 SVG from
+ // `~/core-profiler/components/navigation/woologo`.
+ &__logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto $gap;
+ line-height: 0;
+
+ svg {
+ width: 91px;
+ height: 24px;
+ }
+ }
+
+ &__heading {
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.25;
+ letter-spacing: 0;
+ color: $gray-900;
+ margin: 0 0 $gap;
+ }
+
+ &__intro {
+ color: $gray-700;
+ font-size: 14px;
+ line-height: 1.5;
+ margin: 0 0 $gap-large;
+ max-width: 420px;
+ }
+
+ &__qr {
+ margin-bottom: $gap-large;
+
+ // Centre the QR + meta column as a unit on this single-column page.
+ // The component itself uses a horizontal flex layout in the READY
+ // state (QR on the left, timer + renew on the right), we just
+ // re-anchor that whole row to the centre here.
+ .woocommerce-qr-direct-login {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+
+ .woocommerce-qr-direct-login.woocommerce-qr-direct-login--ready {
+ flex-direction: row;
+ justify-content: center;
+ text-align: left;
+ }
+ }
+
+ &__actions {
+ margin-bottom: $gap-large;
+ }
+
+ &__faq {
+ margin-top: $gap-large;
+ padding-top: 16px;
+ border-top: 1px solid #f0f0f0;
+ color: $gray-700;
+ font-size: 13px;
+ line-height: 1.5;
+ text-align: center;
+
+ a {
+ color: #007cba;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/mobile-app-login/test/index.test.tsx b/plugins/woocommerce/client/admin/client/mobile-app-login/test/index.test.tsx
new file mode 100644
index 00000000000..1418fb05183
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/mobile-app-login/test/index.test.tsx
@@ -0,0 +1,286 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { MobileAppLoginPage } from '../index';
+import {
+ QRLoginTokenStates,
+ useQRLoginToken,
+} from '~/homescreen/mobile-app-modal/components/useQRLoginToken';
+
+// Drive `<QRDirectLoginCode />` from the tests by mocking its shared token
+// hook. The real component is rendered so we exercise the integration
+// surface of this page against the component we claim to reuse.
+jest.mock( '~/homescreen/mobile-app-modal/components/useQRLoginToken', () => {
+ const actual = jest.requireActual(
+ '~/homescreen/mobile-app-modal/components/useQRLoginToken'
+ );
+ return {
+ ...actual,
+ useQRLoginToken: jest.fn(),
+ };
+} );
+
+// Short-circuit the up-front /qr-login-availability probe so these tests
+// reach the QR / error / expired states the assertions care about,
+// rather than getting stuck on the availability spinner. The probe has
+// its own dedicated suite — `useQRLoginAvailability.test.ts`.
+jest.mock(
+ '~/homescreen/mobile-app-modal/components/useQRLoginAvailability',
+ () => {
+ const actual = jest.requireActual(
+ '~/homescreen/mobile-app-modal/components/useQRLoginAvailability'
+ );
+ return {
+ ...actual,
+ useQRLoginAvailability: () => ( {
+ isLoading: false,
+ available: true,
+ reason: null,
+ } ),
+ };
+ }
+);
+
+// Keep tests isolated from analytics side-effects.
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+const mockedUseQRLoginToken = useQRLoginToken as jest.MockedFunction<
+ typeof useQRLoginToken
+>;
+
+const makeReadyState = () => ( {
+ state: QRLoginTokenStates.READY,
+ qrUrl: 'woocommerce://qr-login?token=abc&siteUrl=https%3A%2F%2Fexample.test',
+ secondsRemaining: 300,
+ errorMessage: null,
+ errorCode: null,
+ deviceInfo: null,
+ apUuid: null,
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ fetchToken: jest.fn(),
+ refreshToken: jest.fn(),
+ chooseNumber: jest.fn(),
+ revoke: jest.fn(),
+} );
+
+describe( 'MobileAppLoginPage', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ mockedUseQRLoginToken.mockReturnValue( makeReadyState() );
+ } );
+
+ it( 'renders the heading, scan-first intro, and the QR code', () => {
+ render( <MobileAppLoginPage /> );
+
+ expect(
+ screen.getByRole( 'heading', {
+ name: /Sign in to the Woo mobile app/i,
+ level: 1,
+ } )
+ ).toBeInTheDocument();
+
+ // The scan-first intro mentions the in-app action merchants have to
+ // tap — the exact phrasing is what engineering Happiness reads back
+ // to users on support tickets, so we assert on it literally.
+ expect( screen.getByText( /Scan QR code/ ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( /Open the Woo mobile app on your phone/i )
+ ).toBeInTheDocument();
+
+ // `<QRDirectLoginCode />` in READY state renders its countdown copy.
+ // That copy is the load-bearing signal that the QR is on screen
+ // because the SVG payload itself is not easily queryable.
+ expect( screen.getByText( /Code expires in/i ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders the FAQ link pointing at the help doc', () => {
+ render( <MobileAppLoginPage /> );
+
+ // Copy synced with the homescreen modal so both surfaces share the
+ // same wording ("Any troubles signing in? Check out the FAQ.").
+ const faqLink = screen.getByRole( 'link', {
+ name: /FAQ/i,
+ } );
+ expect( faqLink ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/document/android-ios-apps-login-help-faq/'
+ );
+ } );
+
+ it( 'does not offer a manual refresh while a QR code is still valid', () => {
+ const fetchToken = jest.fn();
+ mockedUseQRLoginToken.mockReturnValue( {
+ ...makeReadyState(),
+ fetchToken,
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ // First mount fires exactly one fetch (from QRDirectLoginCode's
+ // initial `useEffect`).
+ expect( fetchToken ).toHaveBeenCalledTimes( 1 );
+
+ expect(
+ screen.queryByRole( 'button', { name: /Refresh code/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'lets the shared QR component generate a new code after expiry', () => {
+ const refreshToken = jest.fn();
+ mockedUseQRLoginToken.mockReturnValue( {
+ state: QRLoginTokenStates.EXPIRED,
+ qrUrl: null,
+ secondsRemaining: 0,
+ errorMessage: null,
+ errorCode: null,
+ deviceInfo: null,
+ apUuid: null,
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ fetchToken: jest.fn(),
+ refreshToken,
+ chooseNumber: jest.fn(),
+ revoke: jest.fn(),
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /Generate new code/i } )
+ );
+
+ expect( refreshToken ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'renders a recovery action when READY has no QR URL', () => {
+ const refreshToken = jest.fn();
+ mockedUseQRLoginToken.mockReturnValue( {
+ ...makeReadyState(),
+ qrUrl: null,
+ refreshToken,
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ expect(
+ screen.getByText( /could not generate the login code/i )
+ ).toBeInTheDocument();
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /Renew code/i } )
+ );
+
+ expect( refreshToken ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'renders a recovery action when SCANNED has no candidate numbers', () => {
+ const refreshToken = jest.fn();
+ mockedUseQRLoginToken.mockReturnValue( {
+ ...makeReadyState(),
+ state: QRLoginTokenStates.SCANNED,
+ qrUrl: null,
+ candidateNumbers: null,
+ refreshToken,
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ expect(
+ screen.getByText( /could not load the confirmation challenge/i )
+ ).toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'button', { name: /Try again/i } ) );
+
+ expect( refreshToken ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'does not render the magic-link button (regression guard — modal-only feature)', () => {
+ render( <MobileAppLoginPage /> );
+
+ // The onboarding modal ships a "Send the sign-in link" button when
+ // a WordPress.com account is linked. That button must never appear
+ // on this standalone page — the audience here is app-install-ready
+ // merchants who just need to scan, not magic-link recipients.
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Send the sign-in link/i,
+ } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ /Or get a WordPress\.com sign-in link by email/i
+ )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'surfaces the QR error state from useQRLoginToken without breaking the page shell', () => {
+ mockedUseQRLoginToken.mockReturnValue( {
+ state: QRLoginTokenStates.ERROR,
+ qrUrl: null,
+ secondsRemaining: 0,
+ errorMessage: 'QR login requires an HTTPS connection.',
+ errorCode: 'ssl_required',
+ deviceInfo: null,
+ apUuid: null,
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ fetchToken: jest.fn(),
+ refreshToken: jest.fn(),
+ chooseNumber: jest.fn(),
+ revoke: jest.fn(),
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ // The heading and FAQ link are static shell — they must still render
+ // even when the QR surfaces an error from the backend.
+ expect(
+ screen.getByRole( 'heading', {
+ name: /Sign in to the Woo mobile app/i,
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'link', { name: /FAQ/i } )
+ ).toBeInTheDocument();
+
+ // Error text from the hook leaks through the shared component.
+ expect(
+ screen.getByText( /QR login requires an HTTPS connection/i )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders consumed-state revoke errors on the standalone page', () => {
+ mockedUseQRLoginToken.mockReturnValue( {
+ state: QRLoginTokenStates.CONSUMED,
+ qrUrl: null,
+ secondsRemaining: 0,
+ errorMessage: 'Failed to revoke access.',
+ errorCode: null,
+ deviceInfo: { model: 'iPhone 15' },
+ apUuid: 'ap-uuid',
+ candidateNumbers: null,
+ challengeExpiresAt: 0,
+ fetchToken: jest.fn(),
+ refreshToken: jest.fn(),
+ chooseNumber: jest.fn(),
+ revoke: jest.fn(),
+ } );
+
+ render( <MobileAppLoginPage /> );
+
+ expect(
+ screen.getByText( /Signed in successfully on iPhone 15/i )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( /Failed to revoke access/i )
+ ).toBeInTheDocument();
+ } );
+} );
diff --git a/plugins/woocommerce/src/Admin/API/Init.php b/plugins/woocommerce/src/Admin/API/Init.php
index feded8739c7..5c1b10495d7 100644
--- a/plugins/woocommerce/src/Admin/API/Init.php
+++ b/plugins/woocommerce/src/Admin/API/Init.php
@@ -101,6 +101,7 @@ class Init {
'Automattic\WooCommerce\Admin\API\OnboardingPlugins',
'Automattic\WooCommerce\Admin\API\OnboardingProducts',
'Automattic\WooCommerce\Admin\API\MobileAppMagicLink',
+ 'Automattic\WooCommerce\Admin\API\MobileAppQRLogin',
'Automattic\WooCommerce\Admin\API\ShippingPartnerSuggestions',
);
diff --git a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
new file mode 100644
index 00000000000..3eeee5bbf19
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
@@ -0,0 +1,2091 @@
+<?php
+/**
+ * REST API Mobile App QR Login controller.
+ *
+ * Handles requests to generate and exchange QR login tokens for direct mobile
+ * app authentication via Application Passwords. Token generation is gated on
+ * the `manage_woocommerce` capability (administrators and shop managers by
+ * default).
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\API;
+
+use Automattic\WooCommerce\Admin\API\RateLimits\QRLoginRateLimits;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Mobile App QR Login controller.
+ *
+ * @internal
+ */
+class MobileAppQRLogin extends \WC_REST_Data_Controller {
+
+ /**
+ * Endpoint namespace.
+ *
+ * @var string
+ */
+ protected $namespace = 'wc-admin';
+
+ /**
+ * Route base.
+ *
+ * @var string
+ */
+ protected $rest_base = 'mobile-app';
+
+ /**
+ * Token TTL in seconds (5 minutes).
+ */
+ const TOKEN_TTL = 300;
+
+ /**
+ * Transient prefix for QR login tokens.
+ */
+ const TOKEN_TRANSIENT_PREFIX = '_wc_qr_login_token_';
+
+ /**
+ * Max tokens per user per 15-minute window.
+ */
+ const MAX_TOKENS_PER_WINDOW = 5;
+
+ /**
+ * Max exchange attempts per valid token per 15-minute window.
+ */
+ const MAX_EXCHANGE_ATTEMPTS = 10;
+
+ /**
+ * Max invalid-token exchange attempts per IP per 15-minute window.
+ */
+ const MAX_INVALID_EXCHANGE_ATTEMPTS = 100;
+
+ /**
+ * Max invalid-token scan attempts per IP per 15-minute window.
+ */
+ const MAX_INVALID_SCAN_ATTEMPTS = 100;
+
+ /**
+ * Broad anonymous exchange abuse guard per IP per 15-minute window.
+ */
+ const MAX_EXCHANGE_IP_ATTEMPTS = 1000;
+
+ /**
+ * Option prefix for database-backed atomic token claims.
+ */
+ const CLAIM_OPTION_PREFIX = '_wc_qr_login_claim_';
+
+ /**
+ * Scan-claim option prefix. Independent from `CLAIM_OPTION_PREFIX` so the
+ * scan and exchange mutexes can't deadlock each other; they protect
+ * different write windows on the same token record.
+ */
+ const SCAN_CLAIM_OPTION_PREFIX = '_wc_qr_login_scan_claim_';
+
+ /**
+ * Approval-claim option prefix. Prevents concurrent number choices from
+ * racing the one-strike scanned -> approved/rejected transition.
+ */
+ const APPROVE_CLAIM_OPTION_PREFIX = '_wc_qr_login_approve_claim_';
+
+ /**
+ * Stable Application Passwords `app_id` for credentials issued by this
+ * flow. Lets administrators identify QR-issued credentials in the
+ * Application Passwords screen and revoke them in bulk.
+ */
+ const APP_ID = '0b540e2f-86b7-4b8a-8e0c-f61e9bfbde59';
+
+ /**
+ * Transient prefix for the "token consumed" record written after a successful
+ * exchange. The wc-admin UI polls a status endpoint that reads this so it can
+ * transition to a confirmation panel and surface the device that signed in.
+ */
+ const CONSUMED_TRANSIENT_PREFIX = '_wc_qr_login_consumed_';
+
+ /**
+ * Max status checks per user per 15-minute window. The polling client hits
+ * this every ~2.5s while a QR is on screen; 600/15min ≈ 40/min, comfortably
+ * above the polling rate but tight enough to short-circuit a misbehaving
+ * client or a credential-stuffing scan.
+ */
+ const MAX_STATUS_CHECKS_PER_WINDOW = 600;
+
+ /**
+ * Max revoke attempts per user per 15-minute window.
+ */
+ const MAX_REVOKE_ATTEMPTS = 10;
+
+ /**
+ * Whitelisted keys for the `device` payload sent by the mobile app on the
+ * scan call. Anything outside this set is dropped before storage.
+ *
+ * `brand` is Android-only (`Build.BRAND`, e.g. "google", "samsung"); iOS
+ * doesn't have a direct analogue and clients that don't have the field
+ * just leave it absent.
+ *
+ * @var string[]
+ */
+ const DEVICE_PAYLOAD_KEYS = array( 'os', 'os_version', 'model', 'brand', 'app_version' );
+
+ /**
+ * Maximum length (chars) for any individual sanitized device-payload field.
+ * Defends against accidental or hostile bloat ending up in transients and
+ * the Application Password name.
+ */
+ const DEVICE_FIELD_MAX_LENGTH = 64;
+
+ /**
+ * State machine values for the per-token record. Transitions are gated
+ * by an explicit `current_state` check at the top of each handler
+ * (scan/approve/exchange) so the only writers are the handlers themselves.
+ */
+ const STATE_PENDING = 'pending';
+ const STATE_SCANNED = 'scanned';
+ const STATE_APPROVED = 'approved';
+ const STATE_REJECTED = 'rejected';
+ const STATE_EXPIRED = 'expired';
+ const STATE_CONSUMED = 'consumed';
+
+ /**
+ * Pick window after the app scans a QR (seconds). The merchant has this
+ * long to tap the matching number on wc-admin before the session
+ * auto-rejects. Short enough to limit replay; long enough for a confused
+ * user to read the phone, find their browser, and click.
+ */
+ const CHALLENGE_TTL_SECONDS = 90;
+
+ /**
+ * Length (bytes pre-bin2hex) of the exchange-grant nonce minted on
+ * approval. The grant gates the final `/qr-login-exchange` call so an
+ * attacker who learned the token can't race the legit app to exchange
+ * after approval. 32 bytes = 64 hex chars = 256 bits of entropy.
+ */
+ const EXCHANGE_GRANT_BYTES = 32;
+
+ /**
+ * Invalid exchange grants allowed before the token is terminally rejected.
+ */
+ const MAX_INVALID_GRANT_ATTEMPTS = 3;
+
+ /**
+ * Transient prefix mapping `session_id` → `token_hash` so the mobile-side
+ * `/qr-login-session-status` poll can resolve a session id back to the
+ * underlying token record without exposing the original token to the
+ * polling channel.
+ */
+ const SESSION_TRANSIENT_PREFIX = '_wc_qr_login_session_';
+
+ /**
+ * Rate limit for /qr-login-scan (per IP per 15 min).
+ */
+ const MAX_SCAN_PER_WINDOW = 10;
+
+ /**
+ * Rate limit for /qr-login-approve (per user per 15 min).
+ */
+ const MAX_APPROVE_PER_WINDOW = 20;
+
+ /**
+ * Rate limit for /qr-login-session-status (per session id per 15 min).
+ *
+ * Accounts for ~2-s polling over a 90-s challenge window plus headroom.
+ */
+ const MAX_SESSION_STATUS_PER_WINDOW = 60;
+
+ /**
+ * Register routes.
+ *
+ * @return void
+ */
+ public function register_routes() {
+ // Generate a QR login token (requires authentication and `manage_woocommerce` capability).
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-token',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'generate_token' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Exchange a QR login token for Application Password (no authentication required).
+ // The device payload is captured at /qr-login-scan time and sourced from the
+ // approved record — the exchange call only needs the token + grant nonce.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-exchange',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'exchange_token' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'token' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ // Soft-required: the handler enforces presence + validity
+ // via constant-time comparison and returns a clear
+ // `invalid_exchange_grant` 412 if missing. We don't make
+ // it `required: true` at the schema layer because that
+ // would short-circuit earlier checks (HTTPS, invalid
+ // token, rate limit) with a generic WP validation 400
+ // before our diagnostic responses can fire.
+ 'exchange_grant' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Poll for token status (consumed yet?). Used by wc-admin to transition
+ // the modal from "QR shown" to "Signed in successfully on {device}".
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-status',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_status' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(
+ 'token' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Revoke (delete) the Application Password issued by an exchange. The
+ // user must own the AP — verified inside the callback via the WP API.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-revoke',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'revoke_password' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(
+ 'uuid' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Mobile app reports the QR was scanned. Server generates the
+ // number-match challenge and returns the *real* number to the app
+ // only. Public — token + capability flag are the auth.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-scan',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'scan_token' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'token' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ // The device payload is required: it shows up in the
+ // merchant's "match this number" device card, in the
+ // Application Password name, and in the sign-in
+ // notification email. Mobile clients always have these
+ // fields available from the platform SDK (Build.MODEL,
+ // UIDevice.current.model, etc.), so requiring the
+ // payload at the protocol level keeps every downstream
+ // surface honest.
+ 'device' => array(
+ 'required' => true,
+ 'type' => 'object',
+ 'properties' => array(
+ 'os' => array( 'type' => 'string' ),
+ 'os_version' => array( 'type' => 'string' ),
+ 'model' => array( 'type' => 'string' ),
+ 'brand' => array( 'type' => 'string' ),
+ 'app_version' => array( 'type' => 'string' ),
+ ),
+ ),
+ // Capability flag the mobile app sets to advertise that
+ // it implements the number-matching protocol. Reserved
+ // for future protocol bumps that might gate behavior on
+ // further capability bits.
+ 'supports_number_matching' => array(
+ 'required' => true,
+ 'type' => 'boolean',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Merchant taps a number on wc-admin. Server validates against the
+ // stored real number with hash_equals; correct → approved, wrong →
+ // rejected (terminal, no retry).
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-approve',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'approve_token' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(
+ 'token' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'choice' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Mobile app polls this with the session id returned from /scan.
+ // While in `scanned` we say so; on `approved` we hand over the
+ // short-lived `exchange_grant` nonce required by the final
+ // /qr-login-exchange call.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-session-status',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_session_status' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'session_id' => array(
+ 'required' => true,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ 'token_hash' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ // Cheap up-front capability check so wc-admin can render a permanently
+ // disabled QR card (rather than spin up a token request that will fail)
+ // when application passwords are unavailable on this site. Same gate as
+ // the token endpoint so a subscriber cannot probe site configuration.
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/qr-login-availability',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_availability' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+
+ parent::register_routes();
+ }
+
+ /**
+ * Check whether the current user can generate a QR login token.
+ *
+ * Requires the `manage_woocommerce` capability, which covers administrators and
+ * shop managers out of the box. The check is deliberately explicit (not routed
+ * through `wc_rest_check_manager_permissions()`) so it cannot be loosened by the
+ * `woocommerce_rest_check_permissions` filter that other Admin API endpoints share.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
+ * @return \WP_Error|bool True if the user has the required capability, WP_Error otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ unset( $request );
+ // Parameter required by WP REST contract but unused here.
+
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return new \WP_Error(
+ 'woocommerce_rest_cannot_view',
+ __( 'Sorry, you are not allowed to generate a mobile app QR login token.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if Application Passwords are available.
+ *
+ * @return bool
+ */
+ private function are_application_passwords_available() {
+ return function_exists( 'wp_is_application_passwords_available' )
+ && wp_is_application_passwords_available();
+ }
+
+ /**
+ * Return a REST response carrying WordPress' no-cache headers.
+ *
+ * @param array<string, mixed> $data Response payload.
+ * @return \WP_REST_Response
+ */
+ private function rest_ensure_nocache_response( array $data ): \WP_REST_Response {
+ $response = rest_ensure_response( $data );
+
+ foreach ( wp_get_nocache_headers() as $header_name => $header_value ) {
+ if ( false === $header_value ) {
+ continue;
+ }
+
+ $response->header( $header_name, (string) $header_value );
+ }
+
+ return $response;
+ }
+
+ /**
+ * Reason codes returned by `/qr-login-availability` so wc-admin can
+ * tailor the disabled card to the specific cause.
+ */
+ const AVAILABILITY_REASON_HTTPS_REQUIRED = 'https_required';
+ const AVAILABILITY_REASON_AP_UNSUPPORTED = 'application_passwords_unsupported';
+ const AVAILABILITY_REASON_AP_DISABLED_BY_FILTER = 'application_passwords_disabled_by_filter';
+
+ /**
+ * Report whether QR login is currently available on this site.
+ *
+ * Lets wc-admin render a permanently-disabled QR card with the right
+ * explanation up-front, instead of mounting `<QRDirectLoginCode />`,
+ * spinning, calling `/qr-login-token`, and only then showing a generic
+ * error. The reason code is the heuristic best we can do without each
+ * security plugin self-identifying:
+ *
+ * - `https_required` — `is_ssl()` is false or the raw/final `siteurl` is
+ * `http://`. The most common cause is a local dev environment;
+ * production sites without HTTPS can't use QR login at all.
+ * - `application_passwords_unsupported` — WordPress core's own support
+ * gate (`wp_is_application_passwords_supported()`) returns false.
+ * Ships true on every modern WP host that has SSL or is local; false
+ * here typically means the site is non-local + non-SSL.
+ * - `application_passwords_disabled_by_filter` — the WP support gate
+ * passes, but the `wp_is_application_passwords_available` filter
+ * returns false. This is the case where a security plugin (Wordfence,
+ * Solid Security, etc.) or a custom code snippet has explicitly
+ * disabled application passwords. We can't name the exact source from
+ * the filter alone; the docs link in the merchant-facing UI covers it.
+ *
+ * `nocache_headers()` so an upstream cache cannot pin a stale
+ * "unavailable" response for a site that just installed an HTTPS cert.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request The REST request (unused).
+ * @return \WP_REST_Response
+ */
+ public function get_availability( $request ): \WP_REST_Response {
+ unset( $request );
+
+ nocache_headers();
+
+ $site_url = $this->get_secure_site_url();
+ $https_ok = ! is_wp_error( $site_url );
+ $ap_supported = function_exists( 'wp_is_application_passwords_supported' )
+ && wp_is_application_passwords_supported();
+ $ap_available = $this->are_application_passwords_available();
+
+ $https_ok = is_ssl() && $https_ok;
+ $available = $https_ok && $ap_available;
+ $reason = null;
+
+ if ( ! $available ) {
+ if ( ! $https_ok ) {
+ $reason = self::AVAILABILITY_REASON_HTTPS_REQUIRED;
+ } elseif ( ! $ap_supported ) {
+ $reason = self::AVAILABILITY_REASON_AP_UNSUPPORTED;
+ } else {
+ $reason = self::AVAILABILITY_REASON_AP_DISABLED_BY_FILTER;
+ }
+ }
+
+ return $this->rest_ensure_nocache_response(
+ array(
+ 'available' => $available,
+ 'reason' => $reason,
+ )
+ );
+ }
+
+ /**
+ * Check rate limit for token generation.
+ *
+ * @param int $user_id The user ID.
+ * @return bool True if within rate limit.
+ */
+ private function check_generation_rate_limit( $user_id ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_GENERATION, (string) $user_id );
+ }
+
+ /**
+ * Broad anonymous abuse guard for token exchange.
+ *
+ * This intentionally has a high ceiling. It is only meant to slow obvious
+ * unauthenticated floods; valid-token and invalid-token traffic use separate
+ * lower buckets so a few random requests from a shared proxy IP cannot block
+ * legitimate QR login exchanges.
+ *
+ * @return bool True if within rate limit.
+ */
+ private function check_exchange_ip_rate_limit() {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_EXCHANGE_IP, $this->get_client_ip() );
+ }
+
+ /**
+ * Check rate limit for random/nonexistent exchange tokens.
+ *
+ * @return bool True if within rate limit.
+ */
+ private function check_invalid_exchange_rate_limit() {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, $this->get_client_ip() );
+ }
+
+ /**
+ * Check rate limit for random/nonexistent scan tokens.
+ *
+ * @return bool True if within rate limit.
+ */
+ private function check_invalid_scan_rate_limit() {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_INVALID_SCAN, $this->get_client_ip() );
+ }
+
+ /**
+ * Check rate limit for exchange attempts against a valid token.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @return bool True if within rate limit.
+ */
+ private function check_valid_exchange_rate_limit( $token_hash ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_VALID_EXCHANGE, $token_hash );
+ }
+
+ /**
+ * Get the client IP address used as the per-IP rate-limit key.
+ *
+ * Uses `REMOTE_ADDR` exclusively. We intentionally do not honor
+ * `HTTP_X_FORWARDED_FOR` here: the exchange endpoint is unauthenticated, and
+ * without a project-wide trusted-proxy list we cannot tell a legitimate
+ * proxy header from an attacker-supplied one. Trusting the first XFF value
+ * would let any client choose a fresh rate-limit bucket per request and
+ * bypass per-IP caps. On sites behind a CDN/load balancer that all clients
+ * share, REMOTE_ADDR is the proxy IP, so exchange uses broad IP throttling
+ * only as an abuse guard and relies on token-scoped buckets for security.
+ *
+ * @return string
+ */
+ private function get_client_ip() {
+ if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
+ return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
+ }
+ return '';
+ }
+
+ /**
+ * Build the option name used for a token exchange claim.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @return string
+ */
+ private function get_token_claim_key( $token_hash ) {
+ return self::CLAIM_OPTION_PREFIX . $token_hash;
+ }
+
+ /**
+ * Atomically claim a token for exchange using the options table.
+ *
+ * `add_option()` is backed by a unique option_name constraint, so it works
+ * across PHP workers even on default installs without a persistent object
+ * cache. Stale claims are cleaned only if their stored value still matches
+ * the value this request observed, avoiding deletion of another worker's
+ * fresh claim.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @param int $expires_at Unix timestamp when the token expires.
+ * @return bool True if the claim was acquired.
+ */
+ private function claim_token_for_exchange( $token_hash, $expires_at ) {
+ return $this->claim_token_with_option_key(
+ $this->get_token_claim_key( $token_hash ),
+ $expires_at
+ );
+ }
+
+ /**
+ * Atomically claim a token using an option key.
+ *
+ * @param string $claim_key Option key used as the claim mutex.
+ * @param int $expires_at Unix timestamp when the token expires.
+ * @return bool True if the claim was acquired.
+ */
+ private function claim_token_with_option_key( $claim_key, $expires_at ) {
+ $claim_expires_at = max( time() + 30, (int) $expires_at );
+
+ if ( add_option( $claim_key, (string) $claim_expires_at, '', false ) ) {
+ return true;
+ }
+
+ $existing_expires_at = (int) get_option( $claim_key, 0 );
+ if ( $existing_expires_at > 0 && $existing_expires_at <= time() ) {
+ $this->delete_claim_if_value_matches( $claim_key, (string) $existing_expires_at );
+ return add_option( $claim_key, (string) $claim_expires_at, '', false );
+ }
+
+ return false;
+ }
+
+ /**
+ * Delete a claim option only if it still has the value this request observed.
+ *
+ * @param string $claim_key Option key used as the claim mutex.
+ * @param string $observed_claim_value Claim expiry value previously read from the option.
+ * @return bool True if the observed stale claim was deleted.
+ */
+ private function delete_claim_if_value_matches( $claim_key, $observed_claim_value ) {
+ global $wpdb;
+
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
+ $claim_key,
+ $observed_claim_value
+ )
+ );
+
+ if ( false === $result ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'QR login stale-claim cleanup query failed for %s: %s',
+ $claim_key,
+ $wpdb->last_error
+ ),
+ array( 'source' => 'mobile-app-qr-login' )
+ );
+ }
+
+ wp_cache_delete( $claim_key, 'options' );
+
+ return (int) $result > 0;
+ }
+
+ /**
+ * Release a token exchange claim.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @return void
+ */
+ private function release_token_exchange_claim( $token_hash ) {
+ delete_option( $this->get_token_claim_key( $token_hash ) );
+ }
+
+ /**
+ * Atomically claim a token for scan. Mirrors `claim_token_for_exchange()`
+ * (same `add_option()` unique-constraint mutex, same staleness recovery)
+ * but uses its own option key so the scan and exchange windows are
+ * independent.
+ *
+ * Without this, two concurrent `/qr-login-scan` requests both pass the
+ * `state === pending` gate, both write a fresh challenge, and the last
+ * writer wins — leaving the loser's session_id silently orphaned and
+ * pointing at the wrong challenge.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @param int $expires_at Unix timestamp when the token expires.
+ * @return bool True if the claim was acquired.
+ */
+ private function claim_token_for_scan( $token_hash, $expires_at ) {
+ return $this->claim_token_with_option_key(
+ self::SCAN_CLAIM_OPTION_PREFIX . $token_hash,
+ $expires_at
+ );
+ }
+
+ /**
+ * Release a token scan claim owned by this request.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @return void
+ */
+ private function release_token_scan_claim( $token_hash ) {
+ delete_option( self::SCAN_CLAIM_OPTION_PREFIX . $token_hash );
+ }
+
+ /**
+ * Atomically claim a token for approval.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @param int $expires_at Unix timestamp when the claim should expire.
+ * @return bool True if the claim was acquired.
+ */
+ private function claim_token_for_approval( $token_hash, $expires_at ) {
+ return $this->claim_token_with_option_key(
+ self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash,
+ $expires_at
+ );
+ }
+
+ /**
+ * Release a token approval claim owned by this request.
+ *
+ * @param string $token_hash SHA-256 hash of the plaintext token.
+ * @return void
+ */
+ private function release_token_approval_claim( $token_hash ) {
+ delete_option( self::APPROVE_CLAIM_OPTION_PREFIX . $token_hash );
+ }
+
+ /**
+ * Get the remaining storage TTL for a token record.
+ *
+ * @param array<string, mixed> $token_data Token record.
+ * @return int Remaining TTL in seconds.
+ */
+ private function get_token_record_ttl( array $token_data ) {
+ return max(
+ 1,
+ isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] - time() : self::TOKEN_TTL
+ );
+ }
+
+ /**
+ * Delete the session-id to token-hash mapping for a token record.
+ *
+ * @param array<string, mixed> $token_data Token record.
+ * @return void
+ */
+ private function delete_session_mapping_for_record( array $token_data ) {
+ if ( empty( $token_data['challenge']['session_id'] ) ) {
+ return;
+ }
+
+ delete_transient(
+ self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', (string) $token_data['challenge']['session_id'] )
+ );
+ }
+
+ /**
+ * Validate that the configured site URL is HTTPS and return it.
+ *
+ * `is_ssl()` only tells us the current REQUEST is HTTPS — it says nothing about
+ * the canonical site URL WordPress is configured to advertise. `get_site_url()`
+ * itself is also insufficient because it passes its result through
+ * `set_url_scheme()`, which rewrites the scheme to match `is_ssl()` — so
+ * `get_site_url()` will return `https://…` whenever the request happens to be
+ * HTTPS, masking a stale `http://` `siteurl` option underneath. We therefore
+ * check the RAW stored option, which is what reflects admin configuration
+ * and what shows up in reset-password emails, webhooks, canonical redirects,
+ * etc. If that is `http://`, a misconfigured proxy that terminated TLS before
+ * reaching PHP could still cause this endpoint to hand the mobile app a cleartext
+ * site URL for the token-exchange POST.
+ *
+ * We deliberately reject (rather than silently normalizing to `https://`)
+ * because:
+ * 1. The misconfig usually affects other things (reset-password emails,
+ * webhooks, canonical redirects). Failing loudly surfaces it.
+ * 2. Normalizing assumes the site actually serves HTTPS on the same host,
+ * which we cannot verify from within a single request.
+ * 3. A 500 is strictly safer than a leaky success.
+ *
+ * @return string|\WP_Error The HTTPS site URL, or a WP_Error if it is not HTTPS.
+ */
+ private function get_secure_site_url() {
+ // Raw option: what the admin actually configured, before `set_url_scheme()`
+ // inside `get_site_url()` normalizes it based on the current request's scheme.
+ $raw_site_url = get_option( 'siteurl' );
+ $raw_scheme = is_string( $raw_site_url ) ? wp_parse_url( $raw_site_url, PHP_URL_SCHEME ) : null;
+
+ if ( 'https' !== $raw_scheme ) {
+ return new \WP_Error(
+ 'insecure_site_url',
+ __( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ // Use get_site_url() for the returned value so any scheme normalization
+ // or filtering that WordPress applies downstream is preserved, then
+ // validate the final value too. A plugin can still filter `site_url`
+ // after the raw option check above; never hand the mobile app an
+ // HTTP exchange target.
+ $site_url = get_site_url();
+ $final_scheme = wp_parse_url( $site_url, PHP_URL_SCHEME );
+
+ if ( 'https' !== $final_scheme ) {
+ return new \WP_Error(
+ 'insecure_site_url',
+ __( 'QR login cannot be used because the site URL is not configured for HTTPS. Please update the WordPress Address (URL) in Settings → General to use https://.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return $site_url;
+ }
+
+ /**
+ * Generate a QR login token.
+ *
+ * Creates a short-lived one-time token that can be exchanged for an Application
+ * Password by the mobile app. The caller is assumed to have already passed the
+ * `manage_woocommerce` capability check in `get_items_permissions_check()`.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function generate_token( $request ) {
+ unset( $request );
+ // Parameter required by WP REST contract but unused here.
+
+ // Check HTTPS.
+ if ( ! is_ssl() ) {
+ return new \WP_Error(
+ 'ssl_required',
+ __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ // Verify the canonical site URL is HTTPS — is_ssl() alone is not enough
+ // when WordPress is behind a misconfigured proxy.
+ $site_url = $this->get_secure_site_url();
+ if ( is_wp_error( $site_url ) ) {
+ return $site_url;
+ }
+
+ // Check Application Passwords are available.
+ if ( ! $this->are_application_passwords_available() ) {
+ return new \WP_Error(
+ 'application_passwords_unavailable',
+ __( 'Application Passwords are not available on this site.', 'woocommerce' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ // Check rate limit.
+ if ( ! $this->check_generation_rate_limit( get_current_user_id() ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login requests. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ // Generate a cryptographically secure token.
+ $token = wp_generate_password( 64, false );
+ $token_hash = hash( 'sha256', $token );
+ $now = time();
+ $expires_at = $now + self::TOKEN_TTL;
+
+ // Structured state-machine record. Subsequent transitions
+ // (scan/approve/exchange) gate themselves on the current state at the
+ // top of each handler.
+ $token_data = array(
+ 'state' => self::STATE_PENDING,
+ 'created_at' => $now,
+ 'state_at' => $now,
+ 'user_id' => get_current_user_id(),
+ 'site_url' => $site_url,
+ 'expires_at' => $expires_at,
+ );
+
+ set_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash, $token_data, self::TOKEN_TTL );
+
+ // Build the QR URL (deep link for the mobile app).
+ $qr_url = sprintf(
+ 'woocommerce://qr-login?token=%s&siteUrl=%s',
+ rawurlencode( $token ),
+ rawurlencode( $site_url )
+ );
+
+ return rest_ensure_response(
+ array(
+ 'qr_url' => $qr_url,
+ 'expires_at' => $expires_at,
+ 'ttl' => self::TOKEN_TTL,
+ )
+ );
+ }
+
+ /**
+ * Exchange a QR login token for an Application Password.
+ *
+ * This endpoint does not require authentication — the token serves
+ * as the authentication mechanism.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function exchange_token( $request ) {
+ // Refuse to return credentials over a non-HTTPS request.
+ if ( ! is_ssl() ) {
+ return new \WP_Error(
+ 'ssl_required',
+ __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ // Refuse to return credentials bound to a non-HTTPS site URL — see
+ // get_secure_site_url() for rationale. A token that was minted while the
+ // siteurl was still https:// but has since been changed to http:// should
+ // also be refused here.
+ $site_url = $this->get_secure_site_url();
+ if ( is_wp_error( $site_url ) ) {
+ return $site_url;
+ }
+
+ // Defensive sanitize even though the REST `sanitize_callback` already
+ // did so — guards against future refactors that bypass the callback.
+ $token = sanitize_text_field( (string) $request->get_param( 'token' ) );
+ $token_hash = hash( 'sha256', $token );
+ $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
+
+ $token_data = get_transient( $key );
+ if ( ! is_array( $token_data ) ) {
+ if ( ! $this->check_invalid_exchange_rate_limit() ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Broad anonymous abuse guard applies only after token lookup. Random
+ // invalid requests use the invalid-token bucket above so they cannot
+ // exhaust this shared-IP guard for later valid exchanges behind the same
+ // proxy/CDN IP.
+ if ( ! $this->check_exchange_ip_rate_limit() ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ if ( ! $this->check_valid_exchange_rate_limit( $token_hash ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many exchange attempts. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ if ( ! $this->claim_token_for_exchange( $token_hash, isset( $token_data['expires_at'] ) ? (int) $token_data['expires_at'] : time() + self::TOKEN_TTL ) ) {
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Re-read after acquiring the database claim in case another process
+ // consumed or expired the token while this request was waiting.
+ $token_data = get_transient( $key );
+ if ( ! is_array( $token_data ) ) {
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Validate token hasn't expired (belt and suspenders with transient TTL).
+ if ( ! empty( $token_data['expires_at'] ) && time() >= (int) $token_data['expires_at'] ) {
+ delete_transient( $key );
+ $this->delete_session_mapping_for_record( $token_data );
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'token_expired',
+ __( 'QR login token has expired.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Number-matching enforcement: exchange must be preceded by /scan +
+ // /approve. Anything other than `approved` (including `pending` —
+ // scan was skipped — and `scanned` — scan completed but merchant
+ // didn't tap a number yet) is a hard 412.
+ $current_state = isset( $token_data['state'] ) ? (string) $token_data['state'] : self::STATE_PENDING;
+
+ if ( self::STATE_APPROVED !== $current_state ) {
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'qr_login_not_approved',
+ __( 'This QR login session has not been approved.', 'woocommerce' ),
+ array( 'status' => 412 )
+ );
+ }
+
+ // Constant-time grant comparison. The grant is bound to this token
+ // at /approve time and only handed back to the polling app via
+ // /session-status, so an attacker who somehow learned the token
+ // can't race the legit app to exchange after approval.
+ $submitted_grant = (string) $request->get_param( 'exchange_grant' );
+ $stored_grant = isset( $token_data['exchange_grant'] ) ? (string) $token_data['exchange_grant'] : '';
+
+ if ( '' === $stored_grant || ! hash_equals( $stored_grant, $submitted_grant ) ) {
+ $invalid_grant_attempts = isset( $token_data['invalid_grant_attempts'] ) ? (int) $token_data['invalid_grant_attempts'] : 0;
+ ++$invalid_grant_attempts;
+
+ $token_data['invalid_grant_attempts'] = $invalid_grant_attempts;
+ $token_data['invalid_grant_last_attempted_at'] = time();
+
+ if ( $invalid_grant_attempts >= self::MAX_INVALID_GRANT_ATTEMPTS ) {
+ $token_data['state'] = self::STATE_REJECTED;
+ $token_data['state_at'] = time();
+
+ wc_get_logger()->warning(
+ 'QR login rejected after repeated invalid exchange grants',
+ array(
+ 'source' => 'qr-login-security',
+ 'user_id' => isset( $token_data['user_id'] ) ? (int) $token_data['user_id'] : 0,
+ 'ip' => $this->get_client_ip(),
+ )
+ );
+ }
+
+ set_transient( $key, $token_data, $this->get_token_record_ttl( $token_data ) );
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'invalid_exchange_grant',
+ __( 'Invalid exchange grant for this QR login session.', 'woocommerce' ),
+ array( 'status' => 412 )
+ );
+ }//end if
+
+ $user_id = $token_data['user_id'];
+ $user = get_userdata( $user_id );
+
+ if ( ! $user ) {
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'user_not_found',
+ __( 'User associated with this token no longer exists.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ // Application Passwords may have been disabled after the token was minted.
+ if ( ! $this->are_application_passwords_available() ) {
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'application_passwords_unavailable',
+ __( 'Application Passwords are not available on this site.', 'woocommerce' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ // Mirror the permission check WP core performs in
+ // WP_REST_Application_Passwords_Controller::create_item_permissions_check().
+ // Capability or per-user availability filters could have changed in the
+ // window between token generation and exchange.
+ if ( ! user_can( $user, 'create_app_password', $user_id ) ) {
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'rest_cannot_create_application_passwords',
+ __( 'Application passwords are not available for your account. Please contact the site administrator for assistance.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ // Source the device payload from the scan record. /qr-login-scan
+ // requires a device object, so by the time we reach `approved` it's
+ // guaranteed present. Re-sanitize defensively in case the transient
+ // was tampered with at the storage layer.
+ $device_source = isset( $token_data['challenge']['device'] ) && is_array( $token_data['challenge']['device'] )
+ ? $token_data['challenge']['device']
+ : array();
+ $device = $this->sanitize_device_payload( $device_source );
+
+ // Create an Application Password for the mobile app. The name is
+ // descriptive (e.g. "Woo Mobile · iPhone 15 · 2026-04-28") so the user
+ // can identify it later in Users → Profile → Application Passwords.
+ $app_password_result = \WP_Application_Passwords::create_new_application_password(
+ $user_id,
+ array(
+ 'name' => $this->format_application_password_name( $device ),
+ 'app_id' => self::APP_ID,
+ )
+ );
+
+ if ( is_wp_error( $app_password_result ) ) {
+ wc_get_logger()->error(
+ sprintf(
+ 'QR login: failed to create Application Password for user %d: %s',
+ $user_id,
+ $app_password_result->get_error_message()
+ ),
+ array( 'source' => 'mobile-app-qr-login' )
+ );
+ $this->release_token_exchange_claim( $token_hash );
+ return new \WP_Error(
+ 'application_password_failed',
+ __( 'Could not create a mobile-app credential. Please try again, or contact your site administrator.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ list( $new_password, $item ) = $app_password_result;
+
+ // Write a "consumed" record so wc-admin's polling client can transition
+ // from "QR shown" to "Signed in successfully on {device}" and surface
+ // a revoke button. Same TTL as the original token transient — there's
+ // no value in keeping this record longer than the modal that polls it.
+ $consumed_record = array(
+ 'consumed_at' => time(),
+ 'user_id' => $user_id,
+ 'ap_uuid' => $item['uuid'],
+ 'ap_name' => $item['name'],
+ 'device' => $device,
+ );
+ set_transient(
+ self::CONSUMED_TRANSIENT_PREFIX . $token_hash,
+ $consumed_record,
+ self::TOKEN_TTL
+ );
+
+ // One-shot: consume only after the Application Password has been
+ // successfully created and the consumed record is visible to wc-admin's
+ // polling client.
+ delete_transient( $key );
+ $this->delete_session_mapping_for_record( $token_data );
+ $this->release_token_exchange_claim( $token_hash );
+
+ // Notify the merchant out-of-band so they're aware of a fresh sign-in
+ // even when they aren't currently looking at wc-admin. Wrapped in a
+ // try/catch + filter to keep the exchange path uninterrupted if the
+ // site's mailer is misconfigured.
+ $this->maybe_send_sign_in_notification_email( $user, $consumed_record );
+
+ return rest_ensure_response(
+ array(
+ 'success' => true,
+ 'user_login' => $user->user_login,
+ 'user_email' => $user->user_email,
+ 'user_id' => $user_id,
+ 'site_url' => $site_url,
+ 'application_password' => $new_password,
+ 'uuid' => $item['uuid'],
+ )
+ );
+ }
+
+ /**
+ * Get the status of a previously generated QR login token.
+ *
+ * Used by the wc-admin UI to poll while the QR is on screen. Returns one of:
+ * - `pending` — token transient exists, has not been exchanged yet.
+ * - `consumed` — token has been exchanged; payload includes the device that
+ * signed in and the AP UUID so the UI can render the
+ * confirmation panel and (optionally) revoke the AP.
+ * - `expired` — neither transient exists, so the token has expired or
+ * was never valid for this user.
+ *
+ * The user calling this endpoint must be the same user who minted the token.
+ * That's defense in depth — tokens are 64 random chars and not realistically
+ * guessable, but cross-user status reads should be impossible regardless.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_status( $request ) {
+ // Defeat any intermediary cache (Cloudflare, NGINX micro-cache, browser, edge proxy)
+ // that might pin this GET to its first response. Polling endpoints are by definition
+ // state-bearing — every tick must see the live transient. Returning a stale `scanned`
+ // response forever is exactly the symptom we'd see if the cache pins the first hit.
+ nocache_headers();
+
+ $user_id = get_current_user_id();
+
+ if ( ! $this->check_status_rate_limit( $user_id ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login status checks. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ $token = (string) $request->get_param( 'token' );
+ if ( '' === $token ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
+ }
+
+ $token_hash = hash( 'sha256', $token );
+
+ // Consumed lookup first — once a token has been exchanged the main
+ // transient is deleted, but we keep a one-way breadcrumb at the
+ // `_wc_qr_login_consumed_` key so the polling client (which still
+ // has the plaintext token) can render the success panel.
+ $consumed = get_transient( self::CONSUMED_TRANSIENT_PREFIX . $token_hash );
+ if ( is_array( $consumed ) ) {
+ if ( ! isset( $consumed['user_id'] ) || (int) $consumed['user_id'] !== (int) $user_id ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => 'expired' ) );
+ }
+
+ return $this->rest_ensure_nocache_response(
+ array(
+ 'status' => self::STATE_CONSUMED,
+ 'consumed_at' => isset( $consumed['consumed_at'] ) ? (int) $consumed['consumed_at'] : null,
+ 'ap_uuid' => isset( $consumed['ap_uuid'] ) ? (string) $consumed['ap_uuid'] : null,
+ 'ap_name' => isset( $consumed['ap_name'] ) ? (string) $consumed['ap_name'] : null,
+ 'device' => isset( $consumed['device'] ) && is_array( $consumed['device'] ) ? $consumed['device'] : array(),
+ )
+ );
+ }
+
+ $record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
+ if ( ! is_array( $record ) ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
+ }
+
+ // Cross-user defense in depth — same as before.
+ if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
+ }
+
+ $state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
+
+ // Rejected / expired states are terminal — surface them directly so
+ // wc-admin can render the "Login denied" terminal screen.
+ if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => $state ) );
+ }
+
+ if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_EXPIRED ) );
+ }
+
+ // While in `scanned`, surface the shuffled candidate triple and the
+ // device that scanned so wc-admin can render the matching UI. The
+ // REAL number is never returned via this endpoint — only the
+ // shuffled triple of (real + 2 distractors) is, so an XSS / hostile
+ // extension can't read which one is correct from JS state.
+ if ( self::STATE_SCANNED === $state ) {
+ $challenge = isset( $record['challenge'] ) && is_array( $record['challenge'] ) ? $record['challenge'] : array();
+ $numbers = $this->shuffled_candidate_numbers( $challenge );
+
+ return $this->rest_ensure_nocache_response(
+ array(
+ 'status' => self::STATE_SCANNED,
+ 'numbers' => $numbers,
+ 'device' => isset( $challenge['device'] ) && is_array( $challenge['device'] ) ? $challenge['device'] : array(),
+ 'expires_at' => isset( $challenge['expires_at'] ) ? (int) $challenge['expires_at'] : null,
+ )
+ );
+ }
+
+ // Approved (post-tap, pre-exchange) — surface so a wc-admin tab that
+ // reloaded between approve and exchange shows the "Signing in…"
+ // transitional state rather than going back to the QR.
+ if ( self::STATE_APPROVED === $state ) {
+ return $this->rest_ensure_nocache_response( array( 'status' => self::STATE_APPROVED ) );
+ }
+
+ // Pending: same shape as before, plus the new `state` field for
+ // clients that want to switch on it directly.
+ return $this->rest_ensure_nocache_response(
+ array(
+ 'status' => self::STATE_PENDING,
+ 'expires_at' => isset( $record['expires_at'] ) ? (int) $record['expires_at'] : null,
+ )
+ );
+ }
+
+ /**
+ * Revoke (delete) the Application Password issued by a QR login exchange.
+ *
+ * The current user must own the AP being revoked — verified via
+ * `WP_Application_Passwords::get_user_application_password()`. We
+ * deliberately do NOT use `current_user_can( 'edit_user', $user_id )`
+ * because that would let a higher-privilege admin revoke another user's AP
+ * here; the QR flow's revoke surface is for "I just authorized this — undo,"
+ * not for site-wide AP management (which lives at Users → Profile).
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function revoke_password( $request ) {
+ $user_id = get_current_user_id();
+
+ if ( ! $this->check_revoke_rate_limit( $user_id ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login revoke attempts. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ if ( ! $this->are_application_passwords_available() ) {
+ return new \WP_Error(
+ 'application_passwords_unavailable',
+ __( 'Application Passwords are not available on this site.', 'woocommerce' ),
+ array( 'status' => 501 )
+ );
+ }
+
+ $uuid = (string) $request->get_param( 'uuid' );
+
+ // Ownership check: the AP must exist AND belong to the current user.
+ $ap = \WP_Application_Passwords::get_user_application_password( $user_id, $uuid );
+ if ( ! is_array( $ap ) ) {
+ return new \WP_Error(
+ 'application_password_not_found',
+ __( 'No matching Application Password to revoke.', 'woocommerce' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ $deleted = \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
+ if ( true !== $deleted ) {
+ return new \WP_Error(
+ 'application_password_revoke_failed',
+ __( 'Could not revoke the Application Password. Please try again.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return rest_ensure_response(
+ array(
+ 'success' => true,
+ 'uuid' => $uuid,
+ )
+ );
+ }
+
+ /**
+ * Whitelist + sanitize the `device` payload sent by the mobile app.
+ *
+ * Returns an array of strings keyed by the whitelisted keys defined in
+ * `DEVICE_PAYLOAD_KEYS`. Anything outside that whitelist is dropped. Each
+ * value is run through `sanitize_text_field()` and capped at
+ * `DEVICE_FIELD_MAX_LENGTH` characters. The function is total — pass `null`
+ * or anything non-array and you get back `array()`.
+ *
+ * @param mixed $device Raw payload from the request.
+ * @return array<string, string>
+ */
+ private function sanitize_device_payload( $device ) {
+ if ( ! is_array( $device ) ) {
+ return array();
+ }
+
+ $sanitized = array();
+ foreach ( self::DEVICE_PAYLOAD_KEYS as $key ) {
+ if ( ! isset( $device[ $key ] ) || ! is_scalar( $device[ $key ] ) ) {
+ continue;
+ }
+ $value = sanitize_text_field( (string) $device[ $key ] );
+ if ( '' === $value ) {
+ continue;
+ }
+ if ( strlen( $value ) > self::DEVICE_FIELD_MAX_LENGTH ) {
+ $value = substr( $value, 0, self::DEVICE_FIELD_MAX_LENGTH );
+ }
+ $sanitized[ $key ] = $value;
+ }
+
+ return $sanitized;
+ }
+
+ /**
+ * Build a descriptive name for the Application Password issued by the QR
+ * login exchange.
+ *
+ * Preferred: `Woo Mobile · iPhone 15 · 2026-04-28` (model + ISO date).
+ * Falls back to `Woo Mobile · iOS · 2026-04-28` when only the OS is known.
+ * The scan endpoint requires at least model or OS. The legacy fallback is
+ * retained as a defensive guard in case stored token data is corrupted.
+ *
+ * The name is what the merchant sees in WP admin → Users → Profile →
+ * Application Passwords, so it should be human-readable, single-line, and
+ * not contain anything that would only make sense to an engineer.
+ *
+ * @param array<string, string> $device Sanitized device payload.
+ * @return string
+ */
+ private function format_application_password_name( array $device ): string {
+ $model = isset( $device['model'] ) ? trim( $device['model'] ) : '';
+ $os = isset( $device['os'] ) ? trim( $device['os'] ) : '';
+
+ // Prefer model (e.g. "iPhone 15", "Pixel 10"); fall back to the OS
+ // label if a particular device build returns an empty MODEL string.
+ // Both fields come from the platform SDK on the mobile side and are
+ // effectively always populated, but defending against an empty model
+ // is cheaper than chasing the edge case at runtime.
+ $descriptor = '' !== $model ? $model : $os;
+ if ( '' === $descriptor ) {
+ return __( 'WooCommerce Mobile App (QR Login)', 'woocommerce' );
+ }
+
+ // Use the site's configured timezone so the date the merchant sees in
+ // the AP list matches what they'd see in the rest of wp-admin.
+ $date = wp_date( 'Y-m-d' );
+
+ /* translators: 1: device descriptor (model or OS, e.g. "iPhone 15"). 2: ISO date the AP was created. */
+ return sprintf( __( 'Woo Mobile · %1$s · %2$s', 'woocommerce' ), $descriptor, $date );
+ }
+
+ /**
+ * Per-user rate limit for the polling status endpoint.
+ *
+ * @param int $user_id The user ID.
+ * @return bool True if within rate limit.
+ */
+ private function check_status_rate_limit( $user_id ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_STATUS, (string) $user_id );
+ }
+
+ /**
+ * Per-user rate limit for the revoke endpoint.
+ *
+ * @param int $user_id The user ID.
+ * @return bool True if within rate limit.
+ */
+ private function check_revoke_rate_limit( $user_id ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_REVOKE, (string) $user_id );
+ }
+
+ /**
+ * Mobile app reports the QR was scanned. Generates the number-match
+ * challenge, marks the state as `scanned`, and returns the *real* number
+ * + a session id back to the app. Public — the token is the auth.
+ *
+ * Hard-break compat: clients that don't send `supports_number_matching`
+ * get 426 Upgrade Required. The Android Task 7 PR adds the flag; older
+ * apps in the wild see a clear "update required" error.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function scan_token( $request ) {
+ if ( ! is_ssl() ) {
+ return new \WP_Error(
+ 'ssl_required',
+ __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ if ( true !== (bool) $request->get_param( 'supports_number_matching' ) ) {
+ return new \WP_Error(
+ 'mobile_app_update_required',
+ __( 'This Woo mobile app version is no longer supported for QR sign-in. Please update the app and try again.', 'woocommerce' ),
+ array( 'status' => 426 )
+ );
+ }
+
+ $token = (string) $request->get_param( 'token' );
+ $token_hash = hash( 'sha256', $token );
+ $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
+
+ $record = get_transient( $key );
+ if ( ! is_array( $record ) ) {
+ if ( ! $this->check_invalid_scan_rate_limit() ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ if ( ! $this->check_scan_rate_limit() ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login scans. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ $device = $this->sanitize_device_payload( $request->get_param( 'device' ) );
+ if ( empty( $device['model'] ) && empty( $device['os'] ) ) {
+ return new \WP_Error(
+ 'invalid_device',
+ __( 'QR login requires device information from the mobile app.', 'woocommerce' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Atomic mutex on the read-mutate-write window. Without this, two
+ // concurrent scans both pass the state==pending check below and both
+ // write a new challenge — last writer wins, the loser's session_id
+ // is silently orphaned. The state check is kept as defense-in-depth
+ // for any path that bypasses the claim (e.g. the staleness branch).
+ if ( ! $this->claim_token_for_scan(
+ $token_hash,
+ isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL
+ ) ) {
+ return new \WP_Error(
+ 'qr_login_already_scanned',
+ __( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
+ array( 'status' => 409 )
+ );
+ }
+
+ $current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
+ if ( self::STATE_PENDING !== $current_state ) {
+ $this->release_token_scan_claim( $token_hash );
+ return new \WP_Error(
+ 'qr_login_already_scanned',
+ __( 'This QR login session is no longer accepting scans.', 'woocommerce' ),
+ array( 'status' => 409 )
+ );
+ }
+
+ $challenge_numbers = $this->generate_challenge_numbers();
+ $session_id = wp_generate_uuid4();
+ $now = time();
+ $token_expires_at = isset( $record['expires_at'] ) ? (int) $record['expires_at'] : $now + self::TOKEN_TTL;
+
+ if ( $token_expires_at <= $now ) {
+ $this->release_token_scan_claim( $token_hash );
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ $challenge_expires_at = min( $now + self::CHALLENGE_TTL_SECONDS, $token_expires_at );
+ $challenge_ttl = max( 1, $challenge_expires_at - $now );
+
+ // Shuffle the candidate triple ONCE at scan time and persist the chosen ordering so
+ // every subsequent /qr-login-status poll returns the same array. Re-shuffling per-poll
+ // would make the wc-admin tile order flicker every 2.5 s — terrible UX, and makes the
+ // merchant doubt they're reading the right number.
+ $candidates = array_merge( array( $challenge_numbers['real'] ), $challenge_numbers['distractors'] );
+ shuffle( $candidates );
+
+ $record['state'] = self::STATE_SCANNED;
+ $record['state_at'] = $now;
+ $record['challenge'] = array(
+ 'real' => $challenge_numbers['real'],
+ 'distractors' => $challenge_numbers['distractors'],
+ 'shuffled' => $candidates,
+ 'session_id' => $session_id,
+ 'expires_at' => $challenge_expires_at,
+ 'device' => $device,
+ );
+
+ // Re-use whatever TTL the original transient had left. The challenge
+ // window itself is capped to the remaining token lifetime, while the
+ // storage TTL keeps the full challenge visible for normal fresh scans.
+ $ttl_left = max( 1, $token_expires_at - $now );
+ $storage_ttl = min( $ttl_left, self::CHALLENGE_TTL_SECONDS + 30 );
+ set_transient( $key, $record, $storage_ttl );
+
+ // Sibling transient that resolves session_id → token_hash for the
+ // app's session-status poll. Stored hashed so the session id isn't
+ // directly indexable in wp_options.
+ set_transient(
+ self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ),
+ $token_hash,
+ $storage_ttl
+ );
+
+ $this->release_token_scan_claim( $token_hash );
+
+ return rest_ensure_response(
+ array(
+ 'session_id' => $session_id,
+ 'real_number' => $challenge_numbers['real'],
+ 'expires_in' => $challenge_ttl,
+ )
+ );
+ }
+
+ /**
+ * Merchant taps a number on wc-admin. Server validates against the
+ * stored real number with `hash_equals()` (constant-time). Correct →
+ * `approved` + mints `exchange_grant`. Wrong → `rejected` (terminal,
+ * security event logged). One-strike: no retry.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function approve_token( $request ) {
+ $user_id = get_current_user_id();
+
+ if ( ! $this->check_approve_rate_limit( $user_id ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login approval attempts. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ $token = (string) $request->get_param( 'token' );
+ $token_hash = hash( 'sha256', $token );
+ $key = self::TOKEN_TRANSIENT_PREFIX . $token_hash;
+
+ $record = get_transient( $key );
+ if ( ! is_array( $record ) ) {
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ $approval_claim_expires_at = ! empty( $record['challenge']['expires_at'] )
+ ? (int) $record['challenge']['expires_at']
+ : ( isset( $record['expires_at'] ) ? (int) $record['expires_at'] : time() + self::TOKEN_TTL );
+ if ( ! $this->claim_token_for_approval( $token_hash, $approval_claim_expires_at ) ) {
+ return new \WP_Error(
+ 'qr_login_approval_in_progress',
+ __( 'This QR login session is already being approved.', 'woocommerce' ),
+ array( 'status' => 409 )
+ );
+ }
+
+ // Re-read after acquiring the database claim in case another request
+ // approved, rejected, or expired the challenge while this one was waiting.
+ $record = get_transient( $key );
+ if ( ! is_array( $record ) ) {
+ $this->release_token_approval_claim( $token_hash );
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Same cross-user defense as get_status — only the user that minted
+ // the token can approve it.
+ if ( ! isset( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user_id ) {
+ $this->release_token_approval_claim( $token_hash );
+ return new \WP_Error(
+ 'invalid_token',
+ __( 'Invalid or expired QR login token.', 'woocommerce' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
+ $record['state'] = self::STATE_EXPIRED;
+ $record['state_at'] = time();
+ set_transient( $key, $record, 60 );
+ $this->release_token_approval_claim( $token_hash );
+ return new \WP_Error(
+ 'qr_login_expired',
+ __( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ $current_state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
+ if ( self::STATE_SCANNED !== $current_state ) {
+ $this->release_token_approval_claim( $token_hash );
+ return new \WP_Error(
+ 'qr_login_not_scanned',
+ __( 'This QR login session is not waiting for approval.', 'woocommerce' ),
+ array( 'status' => 409 )
+ );
+ }
+
+ // Challenge expiry — normally 90 s after scan, capped by token expiry.
+ if ( ! empty( $record['challenge']['expires_at'] ) && time() > (int) $record['challenge']['expires_at'] ) {
+ $record['state'] = self::STATE_EXPIRED;
+ $record['state_at'] = time();
+ set_transient( $key, $record, 60 );
+ $this->release_token_approval_claim( $token_hash );
+ return new \WP_Error(
+ 'qr_login_expired',
+ __( 'The QR login challenge has expired. Please generate a new code.', 'woocommerce' ),
+ array( 'status' => 410 )
+ );
+ }
+
+ $choice = (string) $request->get_param( 'choice' );
+ $real = isset( $record['challenge']['real'] ) ? (string) $record['challenge']['real'] : '';
+
+ // Constant-time compare. Defends against PHP string-comparison fast
+ // paths that can leak prefix-matching info via timing.
+ if ( '' === $real || ! hash_equals( $real, $choice ) ) {
+ $record['state'] = self::STATE_REJECTED;
+ $record['state_at'] = time();
+ set_transient( $key, $record, 60 );
+
+ wc_get_logger()->warning(
+ 'QR login number-match rejected — wrong choice submitted',
+ array(
+ 'source' => 'qr-login-security',
+ 'user_id' => (int) $user_id,
+ 'ip' => $this->get_client_ip(),
+ 'device' => isset( $record['challenge']['device'] ) ? $record['challenge']['device'] : array(),
+ )
+ );
+
+ $this->release_token_approval_claim( $token_hash );
+ return rest_ensure_response( array( 'state' => self::STATE_REJECTED ) );
+ }
+
+ $record['state'] = self::STATE_APPROVED;
+ $record['state_at'] = time();
+ $record['exchange_grant'] = bin2hex( random_bytes( self::EXCHANGE_GRANT_BYTES ) );
+ $ttl = max(
+ 1,
+ isset( $record['expires_at'] ) ? (int) $record['expires_at'] - time() : self::CHALLENGE_TTL_SECONDS
+ );
+
+ set_transient( $key, $record, $ttl );
+ $this->release_token_approval_claim( $token_hash );
+
+ return rest_ensure_response( array( 'state' => self::STATE_APPROVED ) );
+ }
+
+ /**
+ * Mobile app polls this with the session id from /scan. Returns the
+ * current state of the underlying token, plus — when state is
+ * `approved` — the `exchange_grant` nonce required by /qr-login-exchange.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function get_session_status( $request ) {
+ // Defeat any intermediary cache (Cloudflare, NGINX micro-cache, OkHttp's shared
+ // cache, edge proxy) that might pin this GET to its first response. Polling
+ // endpoints are by definition state-bearing — every tick must see the live
+ // transient. Returning a stale `scanned` response forever is exactly the
+ // symptom we see if the cache pins the first hit.
+ nocache_headers();
+
+ if ( ! is_ssl() ) {
+ return new \WP_Error(
+ 'ssl_required',
+ __( 'QR login requires an HTTPS connection.', 'woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ $session_id = (string) $request->get_param( 'session_id' );
+ $submitted_hash = (string) $request->get_param( 'token_hash' );
+
+ $token_hash = get_transient( self::SESSION_TRANSIENT_PREFIX . hash( 'sha256', $session_id ) );
+ if ( ! is_string( $token_hash ) || '' === $token_hash ) {
+ // Either the session never existed or it has expired. Either way,
+ // surface as expired to the polling app.
+ return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
+ }
+
+ // Bind grant delivery to proof of token knowledge: an attacker who
+ // learns the session_id alone (mobile logs, network capture, debug
+ // output) cannot poll for state transitions and walk away with the
+ // `exchange_grant` the moment the merchant approves. The mobile app
+ // already holds the plaintext token from the QR scan — passing
+ // SHA-256(token) on every poll is essentially free for it.
+ // `hash_equals` for constant-time comparison; `expired` opacity so
+ // we never leak whether the session_id is real or not.
+ if ( ! hash_equals( $token_hash, $submitted_hash ) ) {
+ return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
+ }
+
+ if ( ! $this->check_session_status_rate_limit( $session_id ) ) {
+ return new \WP_Error(
+ 'rate_limit_exceeded',
+ __( 'Too many QR login session-status checks. Please try again later.', 'woocommerce' ),
+ array( 'status' => 429 )
+ );
+ }
+
+ $record = get_transient( self::TOKEN_TRANSIENT_PREFIX . $token_hash );
+ if ( ! is_array( $record ) ) {
+ return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
+ }
+
+ $state = isset( $record['state'] ) ? (string) $record['state'] : self::STATE_PENDING;
+ $response = array( 'state' => $state );
+
+ if ( in_array( $state, array( self::STATE_REJECTED, self::STATE_EXPIRED ), true ) ) {
+ return $this->rest_ensure_nocache_response( $response );
+ }
+
+ if ( ! empty( $record['expires_at'] ) && time() >= (int) $record['expires_at'] ) {
+ return $this->rest_ensure_nocache_response( array( 'state' => self::STATE_EXPIRED ) );
+ }
+
+ if ( self::STATE_APPROVED === $state && ! empty( $record['exchange_grant'] ) ) {
+ $response['exchange_grant'] = (string) $record['exchange_grant'];
+ }
+
+ return $this->rest_ensure_nocache_response( $response );
+ }
+
+ /**
+ * Generate a 1-real + 2-distractor number triple for the match
+ * challenge. Distractors must differ from the real number and from each
+ * other by ≥ 100 — defends against a partial-read leak fingerprinting
+ * the real one (no `042` vs `043` near-misses).
+ *
+ * Uses `random_int()` (CSPRNG-backed) rather than `wp_rand()`, which can
+ * fall back to mt_rand() and is predictable.
+ *
+ * @return array{real: string, distractors: array<int, string>}
+ * @throws \RuntimeException If a valid distractor set cannot be generated.
+ */
+ private function generate_challenge_numbers(): array {
+ $real = random_int( 0, 999 );
+ $valid_candidates = array();
+
+ for ( $candidate = 0; $candidate <= 999; $candidate++ ) {
+ if ( $candidate !== $real && abs( $candidate - $real ) >= 100 ) {
+ $valid_candidates[] = $candidate;
+ }
+ }
+
+ if ( empty( $valid_candidates ) ) {
+ throw new \RuntimeException( 'QR login challenge generator could not find a valid first distractor.' );
+ }
+
+ $first_index = random_int( 0, count( $valid_candidates ) - 1 );
+ $first = $valid_candidates[ $first_index ];
+
+ $second_candidates = array_values(
+ array_filter(
+ $valid_candidates,
+ static function ( $candidate ) use ( $first ) {
+ return abs( $candidate - $first ) >= 100;
+ }
+ )
+ );
+
+ if ( empty( $second_candidates ) ) {
+ throw new \RuntimeException( 'QR login challenge generator could not find a valid second distractor.' );
+ }
+
+ $second = $second_candidates[ random_int( 0, count( $second_candidates ) - 1 ) ];
+ $distractors = array( $first, $second );
+
+ return array(
+ 'real' => str_pad( (string) $real, 3, '0', STR_PAD_LEFT ),
+ 'distractors' => array_map(
+ static function ( $n ) {
+ return str_pad( (string) $n, 3, '0', STR_PAD_LEFT );
+ },
+ $distractors
+ ),
+ );
+ }
+
+ /**
+ * Build the shuffled candidate triple returned by `/qr-login-status`
+ * while in the `scanned` state. The order is fixed at scan time (in
+ * `scan_token`) and stored in `challenge.shuffled` so every poll returns
+ * the same array — re-shuffling per-poll caused visible tile flicker on
+ * wc-admin. Falls back to building + shuffling on the fly for any token
+ * record that predates the persisted-shuffle change.
+ *
+ * @param array<string, mixed> $challenge The challenge payload from the token record.
+ * @return array<int, string>
+ */
+ private function shuffled_candidate_numbers( array $challenge ): array {
+ if ( isset( $challenge['shuffled'] ) && is_array( $challenge['shuffled'] ) ) {
+ return array_map( 'strval', $challenge['shuffled'] );
+ }
+
+ $real = isset( $challenge['real'] ) ? (string) $challenge['real'] : '';
+ $distractors = isset( $challenge['distractors'] ) && is_array( $challenge['distractors'] )
+ ? array_map( 'strval', $challenge['distractors'] )
+ : array();
+
+ $candidates = array_merge( array( $real ), $distractors );
+ shuffle( $candidates );
+ return $candidates;
+ }
+
+ /**
+ * Per-IP rate limit on /qr-login-scan.
+ *
+ * @return bool True if within rate limit.
+ */
+ private function check_scan_rate_limit() {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SCAN, $this->get_client_ip() );
+ }
+
+ /**
+ * Per-user rate limit on /qr-login-approve.
+ *
+ * @param int $user_id The user ID.
+ * @return bool True if within rate limit.
+ */
+ private function check_approve_rate_limit( $user_id ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_APPROVE, (string) $user_id );
+ }
+
+ /**
+ * Per-session rate limit on /qr-login-session-status.
+ *
+ * @param string $session_id The session ID.
+ * @return bool True if within rate limit.
+ */
+ private function check_session_status_rate_limit( $session_id ) {
+ return QRLoginRateLimits::consume( QRLoginRateLimits::BUCKET_SESSION_STATUS, $session_id );
+ }
+
+ /**
+ * Send the merchant a transactional email summarizing a successful QR
+ * sign-in, unless they (or a site owner) opt out via the
+ * `woocommerce_qr_login_should_send_signin_email` filter.
+ *
+ * Wrapped so a misconfigured mailer cannot break the exchange path. Mailer
+ * false returns and exceptions are logged, but delivery never blocks the API
+ * response.
+ *
+ * @param \WP_User $user The user who minted the token (recipient).
+ * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
+ * @return void
+ */
+ private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record ): void {
+ /**
+ * Filter whether to send the QR sign-in notification email.
+ *
+ * Default: true. Return false to suppress the send for a specific
+ * user, environment (e.g. staging), or test run.
+ *
+ * @since 10.9.0
+ *
+ * @param bool $should_send Whether to send the email.
+ * @param \WP_User $user The user who minted the QR token.
+ * @param array<string, mixed> $consumed_record The consumed record about to be emailed (keys: consumed_at, user_id, ap_uuid, ap_name, device).
+ */
+ $should_send = (bool) apply_filters(
+ 'woocommerce_qr_login_should_send_signin_email',
+ true,
+ $user,
+ $consumed_record
+ );
+
+ if ( ! $should_send ) {
+ return;
+ }
+
+ try {
+ if ( ! $this->send_sign_in_notification_email( $user, $consumed_record ) ) {
+ wc_get_logger()->warning(
+ sprintf(
+ 'QR sign-in notification email failed for user %d: wp_mail returned false',
+ $user->ID
+ ),
+ array( 'source' => 'mobile-app-qr-login' )
+ );
+ }
+ } catch ( \Throwable $e ) {
+ // Don't surface mailer failures to the exchange response — the
+ // merchant already has the API result, and the email is best-effort.
+ // Log instead so a misconfigured mailer is observable rather than
+ // invisible. Catch \Throwable so an \Error from the mailer also
+ // stays out of the exchange path.
+ wc_get_logger()->warning(
+ sprintf(
+ 'QR sign-in notification email failed for user %d: %s',
+ $user->ID,
+ $e->getMessage()
+ ),
+ array( 'source' => 'mobile-app-qr-login' )
+ );
+ }
+ }
+
+ /**
+ * Render and dispatch the sign-in notification email.
+ *
+ * Uses `wp_mail()` directly with our own minimal HTML shell rather than
+ * `WC()->mailer()->wrap_message()` — the WC wrapper auto-prepends a small
+ * site-name header that duplicates the subject line shown by most clients
+ * and constrains the body width. Owning the wrapper lets us deliver one
+ * coherent layout.
+ *
+ * @param \WP_User $user Recipient.
+ * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
+ * @return bool True if WordPress accepted the message for delivery.
+ */
+ private function send_sign_in_notification_email( \WP_User $user, array $consumed_record ): bool {
+ $site_name = wp_specialchars_decode(
+ (string) get_bloginfo( 'name' ),
+ ENT_QUOTES
+ );
+
+ /* translators: %s: site name. */
+ $subject = sprintf( __( 'A new device signed in to %s', 'woocommerce' ), $site_name );
+
+ $body_html = $this->render_sign_in_notification_email_body( $user, $consumed_record, $site_name, $subject );
+
+ return wp_mail(
+ $user->user_email,
+ $subject,
+ $body_html,
+ array( 'Content-Type: text/html; charset=UTF-8' )
+ );
+ }
+
+ /**
+ * Render the full HTML email document for the sign-in notification.
+ *
+ * @param \WP_User $user Recipient.
+ * @param array<string, mixed> $consumed_record The consumed record (same shape as `maybe_send_sign_in_notification_email`).
+ * @param string $site_name Decoded site name (passed in to avoid double-decoding).
+ * @param string $subject Email subject; rendered as the in-body heading.
+ * @return string Rendered HTML document.
+ */
+ private function render_sign_in_notification_email_body( \WP_User $user, array $consumed_record, string $site_name, string $subject ): string {
+ $device = $consumed_record['device'] ?? array();
+ $consumed_at = isset( $consumed_record['consumed_at'] ) ? (int) $consumed_record['consumed_at'] : time();
+ $ap_name = $consumed_record['ap_name'] ?? '';
+ $applications_url = admin_url( 'profile.php#application-passwords-section' );
+
+ ob_start();
+ include __DIR__ . '/views/mobile-app-qr-login-signin-email.php';
+ $html = ob_get_clean();
+
+ return is_string( $html ) ? $html : '';
+ }
+}
diff --git a/plugins/woocommerce/src/Admin/API/RateLimits/QRLoginRateLimits.php b/plugins/woocommerce/src/Admin/API/RateLimits/QRLoginRateLimits.php
new file mode 100644
index 00000000000..b0e9ecc1c8e
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/API/RateLimits/QRLoginRateLimits.php
@@ -0,0 +1,221 @@
+<?php
+/**
+ * Rate limiter for mobile app QR login endpoints.
+ *
+ * @package WooCommerce\Admin\API
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\API\RateLimits;
+
+use Automattic\WooCommerce\Admin\API\MobileAppQRLogin;
+use WC_Rate_Limiter;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Counter-based rate limiter for QR login endpoints.
+ *
+ * Uses WooCommerce's `wc_rate_limits` table and an atomic SQL upsert so
+ * concurrent requests cannot bypass a bucket by racing a transient get/set
+ * sequence.
+ *
+ * @internal
+ */
+class QRLoginRateLimits extends WC_Rate_Limiter {
+
+ /**
+ * Generation bucket.
+ */
+ const BUCKET_GENERATION = 'gen';
+
+ /**
+ * Broad exchange-IP bucket.
+ */
+ const BUCKET_EXCHANGE_IP = 'exc_ip';
+
+ /**
+ * Invalid-token exchange bucket.
+ */
+ const BUCKET_INVALID_EXCHANGE = 'exc_invalid';
+
+ /**
+ * Invalid-token scan bucket.
+ */
+ const BUCKET_INVALID_SCAN = 'scn_invalid';
+
+ /**
+ * Valid-token exchange bucket.
+ */
+ const BUCKET_VALID_EXCHANGE = 'exc_token';
+
+ /**
+ * Status polling bucket.
+ */
+ const BUCKET_STATUS = 'sta';
+
+ /**
+ * Revoke endpoint bucket.
+ */
+ const BUCKET_REVOKE = 'rev';
+
+ /**
+ * Scan endpoint bucket.
+ */
+ const BUCKET_SCAN = 'scn';
+
+ /**
+ * Approval endpoint bucket.
+ */
+ const BUCKET_APPROVE = 'apr';
+
+ /**
+ * Session-status polling bucket.
+ */
+ const BUCKET_SESSION_STATUS = 'ss';
+
+ /**
+ * Prefix for QR login rate-limit rows.
+ */
+ const KEY_PREFIX = 'qr_login_';
+
+ /**
+ * Cache group.
+ */
+ const CACHE_GROUP = 'wc_qr_login_rate_limit';
+
+ /**
+ * Build the persisted rate-limit action ID.
+ *
+ * @param string $bucket Bucket name.
+ * @param string $identifier Bucket identifier.
+ * @return string
+ */
+ public static function get_action_id( string $bucket, string $identifier ): string {
+ $normalized_identifier = preg_replace( '/[^A-Za-z0-9:._-]/', '_', trim( $identifier ) );
+ $normalized_identifier = is_string( $normalized_identifier ) && '' !== $normalized_identifier
+ ? $normalized_identifier
+ : 'unknown';
+
+ return substr( self::KEY_PREFIX . $bucket . '_' . $normalized_identifier, 0, 190 );
+ }
+
+ /**
+ * Consume one request from a bucket.
+ *
+ * @param string $bucket Bucket name.
+ * @param string $identifier Bucket identifier.
+ * @return bool True if the request is within the bucket limit.
+ */
+ public static function consume( string $bucket, string $identifier ): bool {
+ global $wpdb;
+
+ $options = self::get_bucket_options( $bucket );
+ if ( null === $options ) {
+ return false;
+ }
+
+ $time = time();
+ $limit = max( 1, (int) $options['limit'] );
+ $rate_limit_expiry = $time + (int) $options['seconds'];
+ $action_id = self::get_action_id( $bucket, $identifier );
+
+ $result = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$wpdb->prefix}wc_rate_limits
+ (`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
+ VALUES
+ (%s, %d, %d)
+ ON DUPLICATE KEY UPDATE
+ `rate_limit_id` = IF(
+ `rate_limit_expiry` < %d OR `rate_limit_remaining` > 0,
+ LAST_INSERT_ID(`rate_limit_id`),
+ LAST_INSERT_ID(0) + `rate_limit_id`
+ ),
+ `rate_limit_remaining` = IF(
+ `rate_limit_expiry` < %d,
+ VALUES(`rate_limit_remaining`),
+ IF(`rate_limit_remaining` > 0, `rate_limit_remaining` - 1, 0)
+ ),
+ `rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
+ ",
+ $action_id,
+ $rate_limit_expiry,
+ $limit - 1,
+ $time,
+ $time,
+ $time
+ )
+ );
+
+ if ( false === $result ) {
+ return false;
+ }
+
+ return (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' ) > 0;
+ }
+
+ /**
+ * Get bucket options.
+ *
+ * @param string $bucket Bucket name.
+ * @return array{limit:int, seconds:int}|null
+ */
+ private static function get_bucket_options( string $bucket ): ?array {
+ switch ( $bucket ) {
+ case self::BUCKET_GENERATION:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_TOKENS_PER_WINDOW,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_EXCHANGE_IP:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_EXCHANGE_IP_ATTEMPTS,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_INVALID_EXCHANGE:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_INVALID_SCAN:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_INVALID_SCAN_ATTEMPTS,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_VALID_EXCHANGE:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_EXCHANGE_ATTEMPTS,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_STATUS:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_STATUS_CHECKS_PER_WINDOW,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_REVOKE:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_REVOKE_ATTEMPTS,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_SCAN:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_SCAN_PER_WINDOW,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_APPROVE:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_APPROVE_PER_WINDOW,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ case self::BUCKET_SESSION_STATUS:
+ return array(
+ 'limit' => MobileAppQRLogin::MAX_SESSION_STATUS_PER_WINDOW,
+ 'seconds' => 15 * MINUTE_IN_SECONDS,
+ );
+ }
+
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/src/Admin/API/views/mobile-app-qr-login-signin-email.php b/plugins/woocommerce/src/Admin/API/views/mobile-app-qr-login-signin-email.php
new file mode 100644
index 00000000000..1f499f5e0e6
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/API/views/mobile-app-qr-login-signin-email.php
@@ -0,0 +1,143 @@
+<?php
+/**
+ * Email body for the QR mobile-app login sign-in notification.
+ *
+ * Renders a full HTML document so we can own the entire shell — the WC
+ * mailer's wrap_message() auto-prepends a small site-name header that
+ * duplicates the subject line and squeezes the body into a narrow column.
+ *
+ * Receives the following locals (validated upstream by the controller):
+ *
+ * @var \WP_User $user Recipient.
+ * @var array<string,string> $device Sanitized device payload (model, brand, os, os_version, app_version).
+ * @var int $consumed_at Unix timestamp of the exchange.
+ * @var string $ap_name Descriptive Application Password name.
+ * @var string $site_name Decoded site name.
+ * @var string $subject Email subject; rendered as the in-body heading.
+ * @var string $applications_url Admin URL to the user's Application Passwords list.
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+
+$device_model = isset( $device['model'] ) ? trim( (string) $device['model'] ) : '';
+$device_brand = isset( $device['brand'] ) ? trim( (string) $device['brand'] ) : '';
+$device_os = isset( $device['os'] ) ? trim( (string) $device['os'] ) : '';
+$device_os_version = isset( $device['os_version'] ) ? trim( (string) $device['os_version'] ) : '';
+$app_version = isset( $device['app_version'] ) ? trim( (string) $device['app_version'] ) : '';
+
+// /qr-login-scan requires a device payload, so by the time this email
+// renders we have at least an OS label. Prefer "{Brand} {Model}" when both
+// are present; fall back to model alone, then to OS.
+if ( '' !== $device_brand && '' !== $device_model ) {
+ $display_name = ucfirst( $device_brand ) . ' ' . $device_model;
+} elseif ( '' !== $device_model ) {
+ $display_name = $device_model;
+} elseif ( '' !== $device_os ) {
+ $display_name = $device_os;
+} else {
+ $display_name = __( 'WooCommerce mobile app', 'woocommerce' );
+}
+
+$os_line = trim( $device_os . ( '' !== $device_os_version ? ' ' . $device_os_version : '' ) );
+$timestamp = (string) wp_date(
+ get_option( 'date_format' ) . ' ' . get_option( 'time_format' ),
+ $consumed_at
+);
+
+$preheader = sprintf(
+ /* translators: 1: device name. 2: site name. */
+ __( '%1$s just signed in to the WooCommerce mobile app for %2$s.', 'woocommerce' ),
+ $display_name,
+ $site_name
+);
+
+$brand_purple_50 = '#873eff';
+$brand_purple_70 = '#5007aa';
+$text_primary = '#1d2327';
+$text_secondary = '#50575e';
+$text_muted = '#757575';
+$divider = '#dcdcde';
+$card_background = '#f6f7f7';
+$body_background = '#f6f7f7';
+$content_background = '#ffffff';
+$font_stack = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif";
+$language = esc_attr( get_bloginfo( 'language' ) );
+?>
+<!DOCTYPE html>
+<html lang="<?php echo $language; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped above. ?>">
+<head>
+<meta charset="UTF-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="x-apple-disable-message-reformatting" />
+<title><?php echo esc_html( $subject ); ?></title>
+</head>
+<body style="margin:0; padding:0; background:<?php echo esc_attr( $body_background ); ?>; font-family:<?php echo esc_attr( $font_stack ); ?>; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;">
+<div style="display:none !important; visibility:hidden; opacity:0; height:0; width:0; max-height:0; max-width:0; overflow:hidden; mso-hide:all; font-size:1px; color:<?php echo esc_attr( $body_background ); ?>; line-height:1px;"><?php echo esc_html( $preheader ); ?></div>
+<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:<?php echo esc_attr( $body_background ); ?>; border-collapse:collapse;">
+<tr>
+<td align="center" style="padding:24px 16px;">
+<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:640px; margin:0 auto; background:<?php echo esc_attr( $content_background ); ?>; border-radius:8px; border-collapse:separate; box-shadow:0 1px 2px rgba(0,0,0,0.04);">
+<tr>
+<td style="height:6px; line-height:6px; font-size:0; background:<?php echo esc_attr( $brand_purple_50 ); ?>; border-top-left-radius:8px; border-top-right-radius:8px;"> </td>
+</tr>
+<tr>
+<td style="padding:32px 40px 8px 40px;">
+<h1 style="margin:0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:24px; line-height:1.3; font-weight:600; letter-spacing:-0.01em; color:<?php echo esc_attr( $text_primary ); ?>;"><?php echo esc_html( $subject ); ?></h1>
+</td>
+</tr>
+<tr>
+<td style="padding:20px 40px 0 40px;">
+<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:<?php echo esc_attr( $card_background ); ?>; border-left:4px solid <?php echo esc_attr( $brand_purple_50 ); ?>; border-radius:6px; border-collapse:separate;">
+<tr>
+<td style="padding:20px 24px;">
+<p style="margin:0 0 6px 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:18px; font-weight:600; line-height:1.3; color:<?php echo esc_attr( $text_primary ); ?>;"><?php echo esc_html( $display_name ); ?></p>
+<?php if ( '' !== $os_line ) : ?>
+<p style="margin:0 0 4px 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:14px; line-height:1.5; color:<?php echo esc_attr( $text_secondary ); ?>;"><?php echo esc_html( $os_line ); ?></p>
+<?php endif; ?>
+<?php if ( '' !== $app_version ) : ?>
+ <?php
+ /* translators: %s: mobile app version, e.g. "24.7.0". */
+ $app_version_line = sprintf( esc_html__( 'App version %s', 'woocommerce' ), esc_html( $app_version ) );
+ ?>
+<p style="margin:0 0 4px 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:14px; line-height:1.5; color:<?php echo esc_attr( $text_secondary ); ?>;"><?php echo $app_version_line; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped above. ?></p>
+<?php endif; ?>
+<p style="margin:8px 0 0 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:13px; line-height:1.5; color:<?php echo esc_attr( $text_muted ); ?>;"><?php echo esc_html( $timestamp ); ?></p>
+</td>
+</tr>
+</table>
+</td>
+</tr>
+<tr>
+<td style="padding:28px 40px 0 40px;">
+<h2 style="margin:0 0 8px 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:18px; line-height:1.4; font-weight:600; color:<?php echo esc_attr( $text_primary ); ?>;"><?php esc_html_e( 'Was this you?', 'woocommerce' ); ?></h2>
+<p style="margin:0 0 24px 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:14px; line-height:1.6; color:<?php echo esc_attr( $text_secondary ); ?>;"><?php esc_html_e( "If you recognise this sign-in, you don't need to do anything. If it wasn't you, revoke access immediately to remove this device.", 'woocommerce' ); ?></p>
+</td>
+</tr>
+<tr>
+<td style="padding:0 40px 32px 40px;">
+<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;">
+<tr>
+<td align="center" bgcolor="<?php echo esc_attr( $brand_purple_50 ); ?>" style="border-radius:6px; padding:14px 32px; mso-padding-alt:14px 32px;"><a href="<?php echo esc_url( $applications_url ); ?>" style="font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:15px; font-weight:600; line-height:1; color:#ffffff; text-decoration:none;"><?php esc_html_e( 'Revoke access', 'woocommerce' ); ?></a></td>
+</tr>
+</table>
+</td>
+</tr>
+<?php
+$manage_link = '<a href="' . esc_url( $applications_url ) . '" style="color:' . esc_attr( $brand_purple_70 ) . '; text-decoration:underline;">';
+$manage_link .= esc_html__( 'Users → Profile → Application Passwords', 'woocommerce' ) . '</a>';
+/* translators: %s: HTML link to the Application Passwords screen. */
+$manage_line = sprintf( esc_html__( 'You can manage all connected devices anytime under %s.', 'woocommerce' ), $manage_link );
+?>
+<tr>
+<td style="padding:0 40px 32px 40px; border-top:1px solid <?php echo esc_attr( $divider ); ?>;">
+<p style="margin:24px 0 0 0; font-family:<?php echo esc_attr( $font_stack ); ?>; font-size:13px; line-height:1.6; color:<?php echo esc_attr( $text_muted ); ?>;"><?php echo $manage_line; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped above. ?></p>
+</td>
+</tr>
+</table>
+</td>
+</tr>
+</table>
+</body>
+</html>
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
new file mode 100644
index 00000000000..aeb5e4fa5e7
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
@@ -0,0 +1,2487 @@
+<?php
+/**
+ * Tests for the MobileAppQRLogin REST controller.
+ *
+ * @package WooCommerce\Admin\Tests\Admin\API
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API;
+
+use Automattic\WooCommerce\Admin\API\MobileAppQRLogin;
+use Automattic\WooCommerce\Admin\API\RateLimits\QRLoginRateLimits;
+use WC_REST_Unit_Test_Case;
+use WP_Application_Passwords;
+use WP_REST_Request;
+
+/**
+ * MobileAppQRLogin API controller test.
+ *
+ * @class MobileAppQRLoginTest.
+ */
+class MobileAppQRLoginTest extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Token generation endpoint.
+ *
+ * @var string
+ */
+ const TOKEN_ENDPOINT = '/wc-admin/mobile-app/qr-login-token';
+
+ /**
+ * Token exchange endpoint.
+ *
+ * @var string
+ */
+ const EXCHANGE_ENDPOINT = '/wc-admin/mobile-app/qr-login-exchange';
+
+ /**
+ * Token status endpoint (polled by wc-admin while the QR is on screen).
+ *
+ * @var string
+ */
+ const STATUS_ENDPOINT = '/wc-admin/mobile-app/qr-login-status';
+
+ /**
+ * Application Password revoke endpoint.
+ *
+ * @var string
+ */
+ const REVOKE_ENDPOINT = '/wc-admin/mobile-app/qr-login-revoke';
+
+ /**
+ * Up-front capability probe endpoint (`/qr-login-availability`).
+ *
+ * @var string
+ */
+ const AVAILABILITY_ENDPOINT = '/wc-admin/mobile-app/qr-login-availability';
+
+ /**
+ * Number-match scan endpoint (Task 7).
+ *
+ * @var string
+ */
+ const SCAN_ENDPOINT = '/wc-admin/mobile-app/qr-login-scan';
+
+ /**
+ * Number-match approve endpoint (Task 7).
+ *
+ * @var string
+ */
+ const APPROVE_ENDPOINT = '/wc-admin/mobile-app/qr-login-approve';
+
+ /**
+ * Mobile-side session-status polling endpoint (Task 7).
+ *
+ * @var string
+ */
+ const SESSION_STATUS_ENDPOINT = '/wc-admin/mobile-app/qr-login-session-status';
+
+ /**
+ * Administrator user ID.
+ *
+ * @var int
+ */
+ private $admin_id;
+
+ /**
+ * Shop manager user ID.
+ *
+ * @var int
+ */
+ private $shop_manager_id;
+
+ /**
+ * Subscriber user ID.
+ *
+ * @var int
+ */
+ private $subscriber_id;
+
+ /**
+ * Original value of $_SERVER['HTTPS'] (if any) before each test.
+ *
+ * @var string|null
+ */
+ private $original_https;
+
+ /**
+ * Original value of $_SERVER['SERVER_PORT'] (if any) before each test.
+ *
+ * Captured alongside HTTPS because `is_ssl()` returns true when
+ * SERVER_PORT === '443', regardless of the HTTPS header.
+ *
+ * @var string|null
+ */
+ private $original_server_port;
+
+ /**
+ * Original value of $_SERVER['HTTP_X_FORWARDED_PROTO'] (if any) before each test.
+ *
+ * Some hosts and reverse-proxy plugins use this header to derive scheme,
+ * so we normalize it across tests to keep `is_ssl()` deterministic.
+ *
+ * @var string|null
+ */
+ private $original_http_x_forwarded_proto;
+
+ /**
+ * Original value of $_SERVER['REMOTE_ADDR'] (if any) before each test.
+ *
+ * @var string|null
+ */
+ private $original_remote_addr;
+
+ /**
+ * Filters registered via force_site_url() so tearDown() can remove them.
+ *
+ * @var array<int, callable>
+ */
+ private $site_url_filters = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $this->shop_manager_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+ $this->subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+
+ // Remember existing $_SERVER values so we can restore them in tearDown.
+ // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Unit-test fixture: values are captured for restoration only, never used for processing.
+ $this->original_https = isset( $_SERVER['HTTPS'] ) ? (string) $_SERVER['HTTPS'] : null;
+ $this->original_server_port = isset( $_SERVER['SERVER_PORT'] ) ? (string) $_SERVER['SERVER_PORT'] : null;
+ $this->original_http_x_forwarded_proto = isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ? (string) $_SERVER['HTTP_X_FORWARDED_PROTO'] : null;
+ $this->original_remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : null;
+ // phpcs:enable WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ // Default to HTTPS on for most tests; disable explicitly where needed.
+ $this->force_https( true );
+
+ // Default to an HTTPS site URL. The WP test framework ships with an
+ // http:// default (example.org), so we explicitly normalize it here so
+ // the controller's `insecure_site_url` check does not reject happy-path
+ // tests. Individual tests override this via force_site_url() when they
+ // need to exercise the http:// rejection path.
+ $this->force_site_url( 'https://example.org' );
+
+ // Default REMOTE_ADDR for exchange IP bucketing tests.
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ wp_set_current_user( 0 );
+
+ wp_delete_user( $this->admin_id );
+ wp_delete_user( $this->shop_manager_id );
+ wp_delete_user( $this->subscriber_id );
+
+ // Clear any QR login data the tests may have written.
+ $this->delete_all_qr_login_data();
+
+ // Restore $_SERVER state.
+ if ( null === $this->original_https ) {
+ unset( $_SERVER['HTTPS'] );
+ } else {
+ $_SERVER['HTTPS'] = $this->original_https;
+ }
+
+ if ( null === $this->original_server_port ) {
+ unset( $_SERVER['SERVER_PORT'] );
+ } else {
+ $_SERVER['SERVER_PORT'] = $this->original_server_port;
+ }
+
+ if ( null === $this->original_http_x_forwarded_proto ) {
+ unset( $_SERVER['HTTP_X_FORWARDED_PROTO'] );
+ } else {
+ $_SERVER['HTTP_X_FORWARDED_PROTO'] = $this->original_http_x_forwarded_proto;
+ }
+
+ if ( null === $this->original_remote_addr ) {
+ unset( $_SERVER['REMOTE_ADDR'] );
+ } else {
+ $_SERVER['REMOTE_ADDR'] = $this->original_remote_addr;
+ }
+
+ unset( $_SERVER['HTTP_X_FORWARDED_FOR'] );
+
+ // Remove any pre_option_siteurl filters force_site_url() registered.
+ foreach ( $this->site_url_filters as $priority => $filter ) {
+ remove_filter( 'pre_option_siteurl', $filter, $priority );
+ }
+ $this->site_url_filters = array();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Toggle HTTPS state for `is_ssl()` checks.
+ *
+ * Disabling HTTPS clears every server indicator that `is_ssl()` (and common
+ * reverse-proxy plugins) inspect — `HTTPS`, `SERVER_PORT`, and
+ * `HTTP_X_FORWARDED_PROTO` — so leftover globals from earlier tests or the
+ * PHPUnit runner can never make a plain-HTTP request appear secure.
+ *
+ * @param bool $on Whether HTTPS should appear enabled.
+ */
+ private function force_https( bool $on ): void {
+ if ( $on ) {
+ $_SERVER['HTTPS'] = 'on';
+ } else {
+ unset(
+ $_SERVER['HTTPS'],
+ $_SERVER['SERVER_PORT'],
+ $_SERVER['HTTP_X_FORWARDED_PROTO']
+ );
+ }
+ }
+
+ /**
+ * Force `get_site_url()` to return the given URL for the duration of the test.
+ *
+ * Uses the `pre_option_siteurl` filter so we do not have to mutate and restore
+ * the real `siteurl` option. Filters stack — the last one registered with the
+ * highest priority wins — so callers can override an earlier setUp() default
+ * by calling this method again. All registered filters are removed in
+ * tearDown().
+ *
+ * @param string $url The URL to return from `get_site_url()`.
+ */
+ private function force_site_url( string $url ): void {
+ // Assign an incrementally higher priority so each subsequent call
+ // overrides the previous one even though the earlier filter is still
+ // registered (we cannot remove a closure by reference cleanly).
+ $priority = 10 + count( $this->site_url_filters );
+ $filter = static function () use ( $url ) {
+ return $url;
+ };
+ add_filter( 'pre_option_siteurl', $filter, $priority );
+ $this->site_url_filters[ $priority ] = $filter;
+ }
+
+ /**
+ * Delete QR login data created by the controller.
+ */
+ private function delete_all_qr_login_data(): void {
+ global $wpdb;
+
+ // Remove token transients keyed by sha256 hash (and their _timeout siblings).
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_\\_wc\\_qr\\_login\\_token\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_\\_wc\\_qr\\_login\\_token\\_%' ESCAPE '\\\\'"
+ );
+
+ // Remove "consumed" transients written by exchange_token() so the
+ // status endpoint can surface them to the wc-admin polling client.
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_\\_wc\\_qr\\_login\\_consumed\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_\\_wc\\_qr\\_login\\_consumed\\_%' ESCAPE '\\\\'"
+ );
+
+ // Remove rate-limit rows.
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->prefix}wc_rate_limits WHERE rate_limit_key LIKE %s",
+ $wpdb->esc_like( QRLoginRateLimits::KEY_PREFIX ) . '%'
+ )
+ );
+
+ // Remove Task 7 session-id to token-hash mapping transients written by /qr-login-scan.
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_\\_wc\\_qr\\_login\\_session\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_\\_wc\\_qr\\_login\\_session\\_%' ESCAPE '\\\\'"
+ );
+
+ // Remove database-backed token exchange claims.
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_wc\\_qr\\_login\\_claim\\_%' ESCAPE '\\\\'"
+ );
+
+ // Remove database-backed token scan claims.
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_wc\\_qr\\_login\\_scan\\_claim\\_%' ESCAPE '\\\\'"
+ );
+
+ // Remove database-backed token approval claims.
+ $wpdb->query(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_wc\\_qr\\_login\\_approve\\_claim\\_%' ESCAPE '\\\\'"
+ );
+
+ wp_cache_flush();
+ }
+
+ /**
+ * Get a QR login rate-limit row.
+ *
+ * @param string $bucket Bucket name.
+ * @param string $identifier Bucket identifier.
+ * @return object|null
+ */
+ private function get_qr_login_rate_limit_row( string $bucket, string $identifier ): ?object {
+ global $wpdb;
+
+ $key = QRLoginRateLimits::get_action_id( $bucket, $identifier );
+
+ return $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT rate_limit_key, rate_limit_expiry, rate_limit_remaining FROM {$wpdb->prefix}wc_rate_limits WHERE rate_limit_key = %s",
+ $key
+ )
+ );
+ }
+
+ /**
+ * Count legacy transient-backed QR login rate-limit rows.
+ *
+ * @return int
+ */
+ private function get_qr_login_rate_limit_transient_count(): int {
+ global $wpdb;
+
+ return (int) $wpdb->get_var(
+ "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_\\_wc\\_qr\\_login\\_rate\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_\\_wc\\_qr\\_login\\_rate\\_%' ESCAPE '\\\\'"
+ );
+ }
+
+ /**
+ * Extract the plaintext token from a `qr_url` deep link.
+ *
+ * @param string $qr_url The `woocommerce://qr-login?...` URL.
+ * @return string The plaintext token.
+ */
+ private function token_from_qr_url( string $qr_url ): string {
+ $query_string = wp_parse_url( $qr_url, PHP_URL_QUERY );
+ $params = array();
+ wp_parse_str( (string) $query_string, $params );
+ return isset( $params['token'] ) ? (string) $params['token'] : '';
+ }
+
+ /**
+ * Build the sha256 token hash used by controller storage keys.
+ *
+ * @param string $token Plaintext token.
+ * @return string
+ */
+ private function token_hash( string $token ): string {
+ return hash( 'sha256', $token );
+ }
+
+ /**
+ * Build the transient key for a plaintext token.
+ *
+ * @param string $token Plaintext token.
+ * @return string
+ */
+ private function token_transient_key( string $token ): string {
+ return MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . $this->token_hash( $token );
+ }
+
+ /**
+ * Build the database claim option key for a plaintext token.
+ *
+ * @param string $token Plaintext token.
+ * @return string
+ */
+ private function token_claim_key( string $token ): string {
+ return MobileAppQRLogin::CLAIM_OPTION_PREFIX . $this->token_hash( $token );
+ }
+
+ /**
+ * Build the database scan-claim option key for a plaintext token.
+ *
+ * @param string $token Plaintext token.
+ * @return string
+ */
+ private function token_scan_claim_key( string $token ): string {
+ return MobileAppQRLogin::SCAN_CLAIM_OPTION_PREFIX . $this->token_hash( $token );
+ }
+
+ /**
+ * Build the database approval-claim option key for a plaintext token.
+ *
+ * @param string $token Plaintext token.
+ * @return string
+ */
+ private function token_approve_claim_key( string $token ): string {
+ return MobileAppQRLogin::APPROVE_CLAIM_OPTION_PREFIX . $this->token_hash( $token );
+ }
+
+ /**
+ * Issue a POST to the token-generation endpoint.
+ *
+ * @return \WP_REST_Response
+ */
+ private function dispatch_generate(): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', self::TOKEN_ENDPOINT );
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Issue a POST to the token-exchange endpoint.
+ *
+ * @param string|null $token Token to exchange. Null omits the parameter.
+ * @param string|null $grant Optional `exchange_grant` nonce returned by /qr-login-approve.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_exchange( ?string $token, ?string $grant = null ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', self::EXCHANGE_ENDPOINT );
+ if ( null !== $token ) {
+ $request->set_param( 'token', $token );
+ }
+ if ( null !== $grant ) {
+ $request->set_param( 'exchange_grant', $grant );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Issue a POST to the /qr-login-scan endpoint (Task 7).
+ *
+ * @param string|null $token Token to scan.
+ * @param array<string, string>|null $device Optional device payload.
+ * @param bool $supports_number_matching Capability flag (defaults true so happy-path tests don't have to set it).
+ * @return \WP_REST_Response
+ */
+ private function dispatch_scan( ?string $token, ?array $device = null, bool $supports_number_matching = true ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', self::SCAN_ENDPOINT );
+ if ( null !== $token ) {
+ $request->set_param( 'token', $token );
+ }
+ // /qr-login-scan now requires `device` at the schema level. Default to
+ // a generic Android-like payload when callers don't supply one — most
+ // existing tests don't care about the device contents, just that the
+ // scan/approve flow advances the state machine.
+ if ( null === $device ) {
+ $device = array(
+ 'os' => 'Android',
+ 'os_version' => '16',
+ 'model' => 'Test Device',
+ 'app_version' => '24.7.0',
+ );
+ }
+ $request->set_param( 'device', $device );
+ $request->set_param( 'supports_number_matching', $supports_number_matching );
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Issue a POST to the /qr-login-approve endpoint (Task 7).
+ *
+ * @param string|null $token Token to approve.
+ * @param string|null $choice Number the merchant tapped on wc-admin.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_approve( ?string $token, ?string $choice ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', self::APPROVE_ENDPOINT );
+ if ( null !== $token ) {
+ $request->set_param( 'token', $token );
+ }
+ if ( null !== $choice ) {
+ $request->set_param( 'choice', $choice );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Issue a GET to the /qr-login-session-status endpoint (Task 7).
+ *
+ * The endpoint binds grant delivery to proof of token knowledge — every
+ * call must send the SHA-256 hash of the plaintext token alongside the
+ * session id. Tests that drive the happy path supply both; tests that
+ * exercise the mismatch / missing-hash branches pass `$token_plaintext`
+ * explicitly (or `null` to omit the hash entirely).
+ *
+ * @param string|null $session_id The session id from /qr-login-scan.
+ * @param string|null $token_plaintext Plaintext token. SHA-256'd before sending. `null` omits the parameter.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_session_status( ?string $session_id, ?string $token_plaintext = null ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'GET', self::SESSION_STATUS_ENDPOINT );
+ if ( null !== $session_id ) {
+ $request->set_param( 'session_id', $session_id );
+ }
+ if ( null !== $token_plaintext ) {
+ $request->set_param( 'token_hash', hash( 'sha256', $token_plaintext ) );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Helper: do the full pre-exchange flow (scan + approve) and return the
+ * data needed to call /qr-login-exchange. Existing happy-path tests that
+ * predate the number-matching step (Task 5/6) call this so they don't
+ * have to be rewritten end-to-end.
+ *
+ * Falls back to a generic device payload when none is supplied — the
+ * /qr-login-scan endpoint requires `device` and `supports_number_matching`,
+ * so tests that don't care about the device contents still need *something*
+ * to thread through the scan call.
+ *
+ * @param string $plaintext Token from /qr-login-token.
+ * @param array<string, string>|null $device Optional device payload.
+ * @return array{session_id: string, exchange_grant: string}
+ */
+ private function complete_pre_exchange_flow( string $plaintext, ?array $device = null ): array {
+ if ( null === $device ) {
+ $device = array(
+ 'os' => 'Android',
+ 'os_version' => '16',
+ 'model' => 'Test Device',
+ 'app_version' => '24.7.0',
+ );
+ }
+
+ // Mobile-side scan (unauthenticated).
+ wp_set_current_user( 0 );
+ $scan_response = $this->dispatch_scan( $plaintext, $device );
+ $this->assertSame( 200, $scan_response->get_status(), 'Pre-exchange helper expected /scan to succeed.' );
+ $scan_data = $scan_response->get_data();
+
+ // Merchant-side approve (auth required).
+ wp_set_current_user( $this->admin_id );
+ $approve_response = $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+ $this->assertSame( 200, $approve_response->get_status(), 'Pre-exchange helper expected /approve to succeed.' );
+
+ // Mobile-side session-status to retrieve the grant.
+ wp_set_current_user( 0 );
+ $session_response = $this->dispatch_session_status( $scan_data['session_id'], $plaintext );
+ $this->assertSame( 200, $session_response->get_status(), 'Pre-exchange helper expected /session-status to succeed.' );
+ $session_data = $session_response->get_data();
+ $this->assertArrayHasKey( 'exchange_grant', $session_data, 'session-status should return the grant once approved.' );
+
+ return array(
+ 'session_id' => $scan_data['session_id'],
+ 'exchange_grant' => $session_data['exchange_grant'],
+ );
+ }
+
+ /**
+ * Issue a POST to the status endpoint.
+ *
+ * @param string|null $token Token to query. Null omits the parameter.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_status( ?string $token ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', self::STATUS_ENDPOINT );
+ if ( null !== $token ) {
+ $request->set_param( 'token', $token );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * Issue a DELETE to the revoke endpoint.
+ *
+ * @param string|null $uuid The Application Password UUID to revoke. Null omits the parameter.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_revoke( ?string $uuid ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'DELETE', self::REVOKE_ENDPOINT );
+ if ( null !== $uuid ) {
+ $request->set_param( 'uuid', $uuid );
+ }
+ return $this->server->dispatch( $request );
+ }
+
+ // -----------------------------------------------------------------------
+ // Permission / capability checks.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox Administrators can generate a token and receive a qr_url on the happy path.
+ */
+ public function test_generate_token_happy_path_for_administrator(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'qr_url', $data );
+ $this->assertArrayHasKey( 'expires_at', $data );
+ $this->assertArrayHasKey( 'ttl', $data );
+ $this->assertSame( MobileAppQRLogin::TOKEN_TTL, $data['ttl'] );
+ $this->assertStringStartsWith( 'woocommerce://qr-login?token=', $data['qr_url'] );
+ $this->assertStringContainsString( '&siteUrl=', $data['qr_url'] );
+ }
+
+ /**
+ * @testdox Shop managers can generate a token because they have the manage_woocommerce capability.
+ */
+ public function test_generate_token_happy_path_for_shop_manager(): void {
+ wp_set_current_user( $this->shop_manager_id );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertArrayHasKey( 'qr_url', $response->get_data() );
+ }
+
+ /**
+ * @testdox Token generation rejects unauthenticated requests with a 401.
+ */
+ public function test_generate_token_rejects_unauthenticated(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token generation rejects subscribers who lack the manage_woocommerce capability.
+ */
+ public function test_generate_token_rejects_subscriber(): void {
+ wp_set_current_user( $this->subscriber_id );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_cannot_view', $response->get_data()['code'] );
+ }
+
+ // -----------------------------------------------------------------------
+ // Generate: error paths.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox Token generation fails with ssl_required when the current request is not over HTTPS.
+ */
+ public function test_generate_token_requires_https(): void {
+ wp_set_current_user( $this->admin_id );
+ $this->force_https( false );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertSame( 'ssl_required', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token generation fails with insecure_site_url when the request is HTTPS but get_site_url() returns an HTTP URL.
+ */
+ public function test_generate_token_rejects_http_site_url_even_when_request_is_https(): void {
+ wp_set_current_user( $this->admin_id );
+ // Simulate a misconfigured proxy: the request appears HTTPS but the canonical
+ // site URL is still http:// (e.g. stale `siteurl` option).
+ $this->force_https( true );
+ $this->force_site_url( 'http://shop.example.com' );
+
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'insecure_site_url', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token generation rejects a site_url filter that downgrades the final URL to HTTP.
+ */
+ public function test_generate_token_rejects_filtered_http_site_url(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $downgrade_site_url = static function () {
+ return 'http://filtered.example.com';
+ };
+ add_filter( 'site_url', $downgrade_site_url );
+
+ try {
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'insecure_site_url', $response->get_data()['code'] );
+ } finally {
+ remove_filter( 'site_url', $downgrade_site_url );
+ }
+ }
+
+ /**
+ * @testdox Token exchange fails with insecure_site_url when the site URL is not HTTPS.
+ */
+ public function test_exchange_token_rejects_http_site_url(): void {
+ // Mint a valid token while the site is correctly configured for HTTPS.
+ wp_set_current_user( $this->admin_id );
+ $plaintext = $this->token_from_qr_url( $this->dispatch_generate()->get_data()['qr_url'] );
+
+ // Then simulate the site URL being downgraded before the exchange happens.
+ wp_set_current_user( 0 );
+ $this->force_site_url( 'http://shop.example.com' );
+
+ $response = $this->dispatch_exchange( $plaintext );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'insecure_site_url', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token exchange fails with ssl_required when the current request is not over HTTPS.
+ */
+ public function test_exchange_token_requires_https(): void {
+ wp_set_current_user( $this->admin_id );
+ $plaintext = $this->token_from_qr_url( $this->dispatch_generate()->get_data()['qr_url'] );
+
+ wp_set_current_user( 0 );
+ $this->force_https( false );
+ $response = $this->dispatch_exchange( $plaintext );
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertSame( 'ssl_required', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ }
+
+ /**
+ * @testdox Token generation fails with 501 when Application Passwords are disabled site-wide.
+ */
+ public function test_generate_token_requires_application_passwords_available(): void {
+ wp_set_current_user( $this->admin_id );
+
+ add_filter( 'wp_is_application_passwords_available', '__return_false' );
+
+ try {
+ $response = $this->dispatch_generate();
+
+ $this->assertSame( 501, $response->get_status() );
+ $this->assertSame( 'application_passwords_unavailable', $response->get_data()['code'] );
+ } finally {
+ remove_filter( 'wp_is_application_passwords_available', '__return_false' );
+ }
+ }
+
+ /**
+ * @testdox Token exchange fails when Application Passwords were disabled after token generation.
+ */
+ public function test_exchange_token_requires_application_passwords_available(): void {
+ // Pre-exchange flow runs while AP is still available.
+ $prep = $this->prepare_exchange_token();
+
+ // Disable APs only for the exchange itself.
+ add_filter( 'wp_is_application_passwords_available', '__return_false' );
+
+ try {
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 501, $response->get_status() );
+ $this->assertSame( 'application_passwords_unavailable', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ $this->assertSame(
+ MobileAppQRLogin::STATE_APPROVED,
+ get_transient( $this->token_transient_key( $prep['plaintext'] ) )['state']
+ );
+ $this->assertFalse( get_option( $this->token_claim_key( $prep['plaintext'] ), false ) );
+ } finally {
+ remove_filter( 'wp_is_application_passwords_available', '__return_false' );
+ }
+ }
+
+ /**
+ * @testdox Token exchange fails when the target user lacks the create_app_password capability.
+ */
+ public function test_exchange_token_requires_create_app_password_capability(): void {
+ $prep = $this->prepare_exchange_token();
+
+ $deny_create_app_password = function ( $caps, $cap ) {
+ if ( 'create_app_password' === $cap ) {
+ return array( 'do_not_allow' );
+ }
+ return $caps;
+ };
+ add_filter( 'map_meta_cap', $deny_create_app_password, 10, 2 );
+
+ try {
+ wp_set_current_user( 0 );
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ $this->assertSame( 'rest_cannot_create_application_passwords', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ $this->assertIsArray( get_transient( $this->token_transient_key( $prep['plaintext'] ) ) );
+ $this->assertFalse( get_option( $this->token_claim_key( $prep['plaintext'] ), false ) );
+ } finally {
+ remove_filter( 'map_meta_cap', $deny_create_app_password, 10 );
+ }
+ }
+
+ /**
+ * @testdox Successful generation persists the sha256 hash of the token in a transient, not the plaintext.
+ */
+ public function test_generate_token_stores_hashed_token_in_transient(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_generate();
+ $this->assertSame( 200, $response->get_status() );
+
+ $plaintext = $this->token_from_qr_url( $response->get_data()['qr_url'] );
+ $this->assertNotEmpty( $plaintext );
+
+ // The plaintext itself should NOT be a transient key.
+ $this->assertFalse(
+ get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . $plaintext ),
+ 'Plaintext token must not be used as the transient key.'
+ );
+
+ // The SHA256 hash of the plaintext IS the transient key.
+ $token_data = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $token_data );
+ $this->assertSame( $this->admin_id, $token_data['user_id'] );
+ $this->assertSame( get_site_url(), $token_data['site_url'] );
+ $this->assertGreaterThan( time(), $token_data['expires_at'] );
+ $this->assertLessThanOrEqual( time() + MobileAppQRLogin::TOKEN_TTL, $token_data['expires_at'] );
+ }
+
+ /**
+ * @testdox Token generation enforces the per-user rate limit and rejects the request after the window cap is reached.
+ */
+ public function test_generate_token_rate_limit_boundary(): void {
+ wp_set_current_user( $this->admin_id );
+
+ for ( $i = 1; $i <= MobileAppQRLogin::MAX_TOKENS_PER_WINDOW; $i++ ) {
+ $response = $this->dispatch_generate();
+ $this->assertSame(
+ 200,
+ $response->get_status(),
+ sprintf( 'Request #%d within the window should succeed.', $i )
+ );
+ }
+
+ $response = $this->dispatch_generate();
+ $this->assertSame( 429, $response->get_status() );
+ $this->assertSame( 'rate_limit_exceeded', $response->get_data()['code'] );
+
+ $row = $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_GENERATION, (string) $this->admin_id );
+ $this->assertNotNull( $row );
+ $this->assertSame(
+ QRLoginRateLimits::get_action_id( QRLoginRateLimits::BUCKET_GENERATION, (string) $this->admin_id ),
+ $row->rate_limit_key
+ );
+ $this->assertSame( 0, (int) $row->rate_limit_remaining );
+ $this->assertSame( 0, $this->get_qr_login_rate_limit_transient_count() );
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_EXCHANGE_IP, '203.0.113.10' ),
+ 'Invalid-token traffic must not consume the broad exchange-IP bucket.'
+ );
+ }
+
+ /**
+ * @testdox Token generation rate limit is bucketed per user so one user exhausting their quota does not affect another.
+ */
+ public function test_generate_token_rate_limit_is_per_user(): void {
+ wp_set_current_user( $this->admin_id );
+ for ( $i = 0; $i < MobileAppQRLogin::MAX_TOKENS_PER_WINDOW; $i++ ) {
+ $this->dispatch_generate();
+ }
+ $this->assertSame( 429, $this->dispatch_generate()->get_status() );
+
+ // Switch to the shop manager — should start with a fresh bucket.
+ wp_set_current_user( $this->shop_manager_id );
+ $this->assertSame( 200, $this->dispatch_generate()->get_status() );
+ }
+
+ // -----------------------------------------------------------------------
+ // Exchange: happy path + error paths.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox Token exchange returns Application Password credentials on the happy path.
+ */
+ public function test_exchange_token_happy_path(): void {
+ $prep = $this->prepare_exchange_token();
+
+ // Unauthenticated exchange (as the mobile app would perform it).
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertTrue( $data['success'] );
+ $this->assertArrayHasKey( 'user_login', $data );
+ $this->assertArrayHasKey( 'user_email', $data );
+ $this->assertArrayHasKey( 'user_id', $data );
+ $this->assertArrayHasKey( 'site_url', $data );
+ $this->assertArrayHasKey( 'application_password', $data );
+ $this->assertArrayHasKey( 'uuid', $data );
+
+ $this->assertSame( $this->admin_id, $data['user_id'] );
+ $this->assertSame( get_site_url(), $data['site_url'] );
+ $this->assertNotEmpty( $data['application_password'] );
+
+ // Confirm the Application Password is actually persisted for the user.
+ $aps = WP_Application_Passwords::get_user_application_passwords( $this->admin_id );
+ $this->assertCount( 1, $aps );
+ $this->assertSame( $data['uuid'], $aps[0]['uuid'] );
+ }
+
+ /**
+ * @testdox Token exchange rejects unknown or tampered tokens with invalid_token.
+ */
+ public function test_exchange_token_rejects_invalid_token(): void {
+ $response = $this->dispatch_exchange( 'definitely-not-a-real-token' );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'invalid_token', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token exchange rejects tokens whose stored expires_at is in the past with token_expired.
+ */
+ public function test_exchange_token_rejects_expired_token(): void {
+ $prep = $this->prepare_exchange_token();
+
+ // Force the stored expires_at into the past, then try to exchange.
+ $transient_key = $this->token_transient_key( $prep['plaintext'] );
+ $token_data = get_transient( $transient_key );
+ $token_data['expires_at'] = time() - 60;
+ set_transient( $transient_key, $token_data, MobileAppQRLogin::TOKEN_TTL );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'token_expired', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Tokens are single-use and the second exchange attempt fails with invalid_token.
+ */
+ public function test_exchange_token_is_single_use(): void {
+ $prep = $this->prepare_exchange_token();
+
+ $first = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $second = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $first->get_status() );
+ $this->assertSame( 401, $second->get_status() );
+ $this->assertSame( 'invalid_token', $second->get_data()['code'] );
+ }
+
+ /**
+ * @testdox An active database claim blocks a duplicate exchange before an Application Password is created.
+ */
+ public function test_exchange_token_active_claim_blocks_duplicate_exchange(): void {
+ $prep = $this->prepare_exchange_token();
+ $claim_key = $this->token_claim_key( $prep['plaintext'] );
+
+ $this->assertTrue(
+ add_option( $claim_key, (string) ( time() + MobileAppQRLogin::TOKEN_TTL ), '', false )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'invalid_token', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ $this->assertSame(
+ MobileAppQRLogin::STATE_APPROVED,
+ get_transient( $this->token_transient_key( $prep['plaintext'] ) )['state']
+ );
+ }
+
+ /**
+ * @testdox A stale database claim is cleaned up and the token can be exchanged.
+ */
+ public function test_exchange_token_reclaims_stale_claim(): void {
+ $prep = $this->prepare_exchange_token();
+ $claim_key = $this->token_claim_key( $prep['plaintext'] );
+
+ $this->assertTrue(
+ add_option( $claim_key, (string) ( time() - 60 ), '', false )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ $this->assertFalse( get_option( $claim_key, false ) );
+ }
+
+ /**
+ * @testdox Token exchange returns 404 user_not_found when the associated user was deleted between generation and exchange.
+ */
+ public function test_exchange_token_rejects_missing_user(): void {
+ wp_set_current_user( $this->admin_id );
+ $prep = $this->prepare_exchange_token();
+
+ wp_delete_user( $this->admin_id );
+ // Avoid double-delete in tearDown().
+ $this->admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'user_not_found', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Token exchange surfaces application_password_failed with status 500 when Application Password creation fails.
+ */
+ public function test_exchange_token_handles_application_password_creation_failure(): void {
+ $prep = $this->prepare_exchange_token();
+
+ $deny_meta = function ( $check, $object_id, $meta_key ) {
+ unset( $object_id );
+ if ( '_application_passwords' === $meta_key ) {
+ return false;
+ }
+ return $check;
+ };
+ add_filter( 'update_user_metadata', $deny_meta, 10, 3 );
+
+ try {
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'application_password_failed', $response->get_data()['code'] );
+ $this->assertSame(
+ MobileAppQRLogin::STATE_APPROVED,
+ get_transient( $this->token_transient_key( $prep['plaintext'] ) )['state']
+ );
+ $this->assertFalse( get_option( $this->token_claim_key( $prep['plaintext'] ), false ) );
+ } finally {
+ remove_filter( 'update_user_metadata', $deny_meta, 10 );
+ }
+
+ $retry = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $retry->get_status() );
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ }
+
+ /**
+ * @testdox Token exchange enforces the invalid-token rate limit and rejects requests after the window cap is reached.
+ */
+ public function test_exchange_token_rate_limit_boundary(): void {
+ // Burn the invalid-token quota with random tokens.
+ for ( $i = 1; $i <= MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS; $i++ ) {
+ $response = $this->dispatch_exchange( 'bad-token-' . $i );
+ $this->assertSame(
+ 401,
+ $response->get_status(),
+ sprintf( 'Invalid-token response expected within the rate window, got %d on attempt %d.', $response->get_status(), $i )
+ );
+ }
+
+ $response = $this->dispatch_exchange( 'bad-token-final' );
+ $this->assertSame( 429, $response->get_status() );
+ $this->assertSame( 'rate_limit_exceeded', $response->get_data()['code'] );
+
+ $row = $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, '203.0.113.10' );
+ $this->assertNotNull( $row );
+ $this->assertSame(
+ QRLoginRateLimits::get_action_id( QRLoginRateLimits::BUCKET_INVALID_EXCHANGE, '203.0.113.10' ),
+ $row->rate_limit_key
+ );
+ $this->assertSame( 0, (int) $row->rate_limit_remaining );
+ $this->assertSame( 0, $this->get_qr_login_rate_limit_transient_count() );
+ }
+
+ /**
+ * @testdox Invalid-token exchange rate limit is bucketed per IP so a different IP gets its own fresh quota.
+ */
+ public function test_exchange_token_rate_limit_is_per_ip(): void {
+ $_SERVER['REMOTE_ADDR'] = '203.0.113.10';
+ for ( $i = 0; $i < MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS; $i++ ) {
+ $this->dispatch_exchange( 'bad-' . $i );
+ }
+ $this->assertSame( 429, $this->dispatch_exchange( 'bad-final' )->get_status() );
+
+ // Different IP → fresh bucket.
+ $_SERVER['REMOTE_ADDR'] = '198.51.100.25';
+ $this->assertSame( 401, $this->dispatch_exchange( 'new-ip' )->get_status() );
+ }
+
+ /**
+ * @testdox QR login rate limits are persisted in wc_rate_limits and reset after expiry.
+ */
+ public function test_qr_login_rate_limits_are_persistent_and_reset_after_expiry(): void {
+ global $wpdb;
+
+ $bucket = QRLoginRateLimits::BUCKET_INVALID_EXCHANGE;
+ $identifier = '203.0.113.10';
+ $key = QRLoginRateLimits::get_action_id( $bucket, $identifier );
+
+ for ( $i = 0; $i < MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS; $i++ ) {
+ $this->assertTrue( QRLoginRateLimits::consume( $bucket, $identifier ) );
+ }
+
+ $this->assertFalse( QRLoginRateLimits::consume( $bucket, $identifier ) );
+
+ $row = $this->get_qr_login_rate_limit_row( $bucket, $identifier );
+ $this->assertNotNull( $row );
+ $this->assertSame( $key, $row->rate_limit_key );
+ $this->assertSame( 0, (int) $row->rate_limit_remaining );
+ $this->assertSame( 0, $this->get_qr_login_rate_limit_transient_count() );
+
+ $wpdb->update(
+ $wpdb->prefix . 'wc_rate_limits',
+ array( 'rate_limit_expiry' => time() - 1 ),
+ array( 'rate_limit_key' => $key ),
+ array( '%d' ),
+ array( '%s' )
+ );
+
+ $this->assertTrue( QRLoginRateLimits::consume( $bucket, $identifier ) );
+
+ $row = $this->get_qr_login_rate_limit_row( $bucket, $identifier );
+ $this->assertNotNull( $row );
+ $this->assertSame( MobileAppQRLogin::MAX_INVALID_EXCHANGE_ATTEMPTS - 1, (int) $row->rate_limit_remaining );
+ }
+
+ /**
+ * @testdox Random invalid exchange attempts do not exhaust a later valid-token exchange from the same IP.
+ */
+ public function test_invalid_exchange_attempts_do_not_block_valid_token_from_same_ip(): void {
+ $prep = $this->prepare_exchange_token();
+
+ for ( $i = 0; $i < MobileAppQRLogin::MAX_EXCHANGE_ATTEMPTS; $i++ ) {
+ $this->assertSame( 401, $this->dispatch_exchange( 'random-invalid-' . $i )->get_status() );
+ }
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_EXCHANGE_IP, '203.0.113.10' ),
+ 'Invalid-token traffic must not create a broad exchange-IP row.'
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_EXCHANGE_IP, '203.0.113.10' ),
+ 'Valid-token exchange traffic should still consume the broad exchange-IP guard.'
+ );
+ }
+
+ /**
+ * @testdox Valid-token exchange attempts are limited per token.
+ */
+ public function test_valid_exchange_attempts_are_limited_per_token(): void {
+ $prep = $this->prepare_exchange_token();
+
+ add_filter( 'wp_is_application_passwords_available', '__return_false' );
+
+ try {
+ for ( $i = 1; $i <= MobileAppQRLogin::MAX_EXCHANGE_ATTEMPTS; $i++ ) {
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame(
+ 501,
+ $response->get_status(),
+ sprintf( 'Application Passwords unavailable response expected before the valid-token cap, got %d on attempt %d.', $response->get_status(), $i )
+ );
+ }
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 429, $response->get_status() );
+ $this->assertSame( 'rate_limit_exceeded', $response->get_data()['code'] );
+ $this->assertSame(
+ MobileAppQRLogin::STATE_APPROVED,
+ get_transient( $this->token_transient_key( $prep['plaintext'] ) )['state']
+ );
+ $this->assertFalse( get_option( $this->token_claim_key( $prep['plaintext'] ), false ) );
+ } finally {
+ remove_filter( 'wp_is_application_passwords_available', '__return_false' );
+ }//end try
+ }
+
+ // -----------------------------------------------------------------------
+ // Schema / response shape.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox The generate-token response exposes exactly qr_url, expires_at, and ttl.
+ */
+ public function test_generate_response_schema(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $data = $this->dispatch_generate()->get_data();
+
+ $this->assertEqualsCanonicalizing(
+ array( 'qr_url', 'expires_at', 'ttl' ),
+ array_keys( $data )
+ );
+ $this->assertIsString( $data['qr_url'] );
+ $this->assertIsInt( $data['expires_at'] );
+ $this->assertIsInt( $data['ttl'] );
+ }
+
+ /**
+ * @testdox The exchange-token response exposes the documented fields on success.
+ */
+ public function test_exchange_response_schema(): void {
+ $prep = $this->prepare_exchange_token();
+ $data = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] )->get_data();
+
+ $this->assertEqualsCanonicalizing(
+ array( 'success', 'user_login', 'user_email', 'user_id', 'site_url', 'application_password', 'uuid' ),
+ array_keys( $data )
+ );
+ $this->assertTrue( $data['success'] );
+ $this->assertIsString( $data['user_login'] );
+ $this->assertIsString( $data['user_email'] );
+ $this->assertIsInt( $data['user_id'] );
+ $this->assertIsString( $data['site_url'] );
+ $this->assertIsString( $data['application_password'] );
+ $this->assertIsString( $data['uuid'] );
+ }
+
+ /**
+ * @testdox The QR URL scheme is stable because the mobile apps depend on it exactly.
+ */
+ public function test_qr_url_scheme_is_stable(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $qr_url = $this->dispatch_generate()->get_data()['qr_url'];
+
+ $this->assertMatchesRegularExpression(
+ '#^woocommerce://qr-login\?token=[^&]+&siteUrl=[^&]+$#',
+ $qr_url
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Consumed-status persistence on exchange success.
+ // -----------------------------------------------------------------------
+
+ /**
+ * Convenience: generate a token as $admin_id and return the plaintext.
+ *
+ * @return string Plaintext token.
+ */
+ private function generate_token_as_admin(): string {
+ wp_set_current_user( $this->admin_id );
+ $plaintext = $this->token_from_qr_url( $this->dispatch_generate()->get_data()['qr_url'] );
+ wp_set_current_user( 0 );
+ return $plaintext;
+ }
+
+ /**
+ * Generate a token AND complete the scan + approve handshake so the
+ * token is ready for /qr-login-exchange. Returns the plaintext token
+ * plus the `exchange_grant` nonce required by the exchange call.
+ *
+ * Use this in tests that exercise the exchange-side behaviour
+ * (consumed transient, AP creation, email dispatch, etc.) without
+ * caring about the number-matching details.
+ *
+ * @param array<string, string>|null $device Optional device payload to thread through scan.
+ * @return array{plaintext: string, session_id: string, exchange_grant: string}
+ */
+ private function prepare_exchange_token( ?array $device = null ): array {
+ $plaintext = $this->generate_token_as_admin();
+ $flow = $this->complete_pre_exchange_flow( $plaintext, $device );
+
+ // Leave the test in unauthenticated state — the mobile app calls
+ // /qr-login-exchange without a logged-in cookie.
+ wp_set_current_user( 0 );
+
+ return array(
+ 'plaintext' => $plaintext,
+ 'session_id' => $flow['session_id'],
+ 'exchange_grant' => $flow['exchange_grant'],
+ );
+ }
+
+ /**
+ * @testdox Successful exchange persists a consumed-status transient keyed by the same hash as the token.
+ */
+ public function test_exchange_token_persists_consumed_status(): void {
+ $device = array(
+ 'os' => 'Android',
+ 'os_version' => '14',
+ 'model' => 'Pixel 8 Pro',
+ 'brand' => 'google',
+ 'app_version' => '24.7.0',
+ );
+ $prep = $this->prepare_exchange_token( $device );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+
+ $consumed = get_transient( MobileAppQRLogin::CONSUMED_TRANSIENT_PREFIX . hash( 'sha256', $prep['plaintext'] ) );
+ $this->assertIsArray( $consumed, 'A consumed-status transient should be written on successful exchange.' );
+ $this->assertArrayHasKey( 'consumed_at', $consumed );
+ $this->assertArrayHasKey( 'user_id', $consumed );
+ $this->assertArrayHasKey( 'ap_uuid', $consumed );
+ $this->assertArrayHasKey( 'ap_name', $consumed );
+ $this->assertArrayHasKey( 'device', $consumed );
+ $this->assertSame( $this->admin_id, (int) $consumed['user_id'] );
+ $this->assertSame( $response->get_data()['uuid'], $consumed['ap_uuid'] );
+ $this->assertSame(
+ array(
+ 'os' => 'Android',
+ 'os_version' => '14',
+ 'model' => 'Pixel 8 Pro',
+ 'brand' => 'google',
+ 'app_version' => '24.7.0',
+ ),
+ $consumed['device']
+ );
+ }
+
+ /**
+ * @testdox Exchange after scan with a device payload uses the model and date in the Application Password name.
+ */
+ public function test_exchange_token_with_device_payload_sets_descriptive_ap_name(): void {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'os_version' => '17.5',
+ 'model' => 'iPhone 15',
+ 'app_version' => '24.7.0',
+ )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+
+ $aps = WP_Application_Passwords::get_user_application_passwords( $this->admin_id );
+ $this->assertCount( 1, $aps );
+ $this->assertMatchesRegularExpression(
+ '#^Woo Mobile · iPhone 15 · \d{4}-\d{2}-\d{2}$#u',
+ $aps[0]['name'],
+ 'AP name should be "Woo Mobile · {model} · {YYYY-MM-DD}".'
+ );
+ }
+
+ /**
+ * @testdox Exchange whitelists device-payload keys, drops anything outside the whitelist, and caps each field at 64 characters.
+ */
+ public function test_exchange_token_sanitizes_device_fields(): void {
+ $long = str_repeat( 'A', 100 );
+
+ // Each value asserts a different sanitization invariant: tags stripped
+ // by sanitize_text_field, length capped at 64, and unknown keys
+ // dropped (whitelist enforcement).
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS<script>',
+ 'os_version' => '17.5',
+ 'model' => $long,
+ 'app_version' => '24.7.0',
+ 'rogue_field' => 'should-be-dropped',
+ )
+ );
+
+ $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $consumed = get_transient( MobileAppQRLogin::CONSUMED_TRANSIENT_PREFIX . hash( 'sha256', $prep['plaintext'] ) );
+ $this->assertIsArray( $consumed );
+ $this->assertSame( 'iOS', $consumed['device']['os'], 'sanitize_text_field should strip tags.' );
+ $this->assertSame( str_repeat( 'A', 64 ), $consumed['device']['model'], 'Each field is capped at 64 chars.' );
+ $this->assertArrayNotHasKey(
+ 'rogue_field',
+ $consumed['device'],
+ 'Anything outside the whitelist must be dropped server-side.'
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Status endpoint.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox Status endpoint returns pending when a token has been generated but not yet exchanged.
+ */
+ public function test_get_status_returns_pending_when_token_active(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_status( $plaintext );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'pending', $data['status'] );
+ $this->assertArrayHasKey( 'expires_at', $data );
+ $this->assertIsInt( $data['expires_at'] );
+ }
+
+ /**
+ * @testdox Status endpoint returns consumed with the device payload after a successful exchange.
+ */
+ public function test_get_status_returns_consumed_with_device_info(): void {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'Android',
+ 'os_version' => '14',
+ 'model' => 'Pixel 8',
+ 'app_version' => '24.7.0',
+ )
+ );
+
+ $exchange = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $exchange->get_status() );
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_status( $prep['plaintext'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'consumed', $data['status'] );
+ $this->assertSame( $exchange->get_data()['uuid'], $data['ap_uuid'] );
+ $this->assertSame( 'Pixel 8', $data['device']['model'] );
+ $this->assertSame( 'Android', $data['device']['os'] );
+ $this->assertIsInt( $data['consumed_at'] );
+ $this->assertNotEmpty( $data['ap_name'] );
+ }
+
+ /**
+ * @testdox Status endpoint returns expired when neither token nor consumed transient exists.
+ */
+ public function test_get_status_returns_expired_when_neither_transient_exists(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_status( 'never-minted-this-token' );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'expired', $response->get_data()['status'] );
+ }
+
+ /**
+ * @testdox Status endpoint returns expired immediately when the token is empty.
+ */
+ public function test_get_status_returns_expired_when_token_empty(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_status( '' );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'expired', $response->get_data()['status'] );
+ }
+
+ /**
+ * @testdox Status endpoint hides another user's token state and reports it as expired (defense in depth).
+ */
+ public function test_get_status_rejects_other_users(): void {
+ // Admin mints + exchanges the token.
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+ $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ // A different shop manager polls the same token. Token guess is
+ // astronomical, but cross-user reads must still be opaque.
+ wp_set_current_user( $this->shop_manager_id );
+ $response = $this->dispatch_status( $prep['plaintext'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'expired', $response->get_data()['status'] );
+ }
+
+ /**
+ * @testdox Status endpoint rejects subscribers who lack the manage_woocommerce capability.
+ */
+ public function test_get_status_rejects_subscriber(): void {
+ wp_set_current_user( $this->subscriber_id );
+
+ $response = $this->dispatch_status( 'whatever' );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ }
+
+ // -----------------------------------------------------------------------
+ // Revoke endpoint.
+ // -----------------------------------------------------------------------
+
+ /**
+ * @testdox Revoke endpoint deletes the Application Password issued by a successful exchange.
+ */
+ public function test_revoke_password_happy_path(): void {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+ $exchange = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $exchange->get_status() );
+ $uuid = $exchange->get_data()['uuid'];
+
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_revoke( $uuid );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data()['success'] );
+ $this->assertSame( $uuid, $response->get_data()['uuid'] );
+ $this->assertCount(
+ 0,
+ WP_Application_Passwords::get_user_application_passwords( $this->admin_id ),
+ 'The Application Password must be gone after a successful revoke.'
+ );
+ }
+
+ /**
+ * @testdox Revoke endpoint returns 404 when the UUID belongs to a different user.
+ */
+ public function test_revoke_password_rejects_when_uuid_belongs_to_another_user(): void {
+ // Admin mints + exchanges. AP belongs to admin.
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+ $exchange = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $uuid = $exchange->get_data()['uuid'];
+
+ // Shop manager tries to revoke admin's AP. This must fail with 404 —
+ // we don't even leak that the AP exists.
+ wp_set_current_user( $this->shop_manager_id );
+ $response = $this->dispatch_revoke( $uuid );
+
+ $this->assertSame( 404, $response->get_status() );
+ $this->assertSame( 'application_password_not_found', $response->get_data()['code'] );
+
+ // And the admin's AP is untouched.
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ }
+
+ /**
+ * @testdox Revoke endpoint rejects subscribers who lack the manage_woocommerce capability.
+ */
+ public function test_revoke_password_rejects_subscriber(): void {
+ wp_set_current_user( $this->subscriber_id );
+
+ $response = $this->dispatch_revoke( 'whatever-uuid' );
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ }
+
+ // -----------------------------------------------------------------------
+ // Sign-in notification email (Task 6).
+ // -----------------------------------------------------------------------
+
+ /**
+ * Capture wp_mail() calls into a local array via the `pre_wp_mail` filter.
+ *
+ * The filter must return a non-null value to short-circuit the actual
+ * mail send. The captured atts include `to`,
+ * `subject`, `message`, and `headers`. Caller must remove the filter via
+ * the returned remover closure.
+ *
+ * @param bool $send_result Value the filter should return from wp_mail().
+ * @return array{captures: array<int, array<string, mixed>>, remove: callable}
+ */
+ private function capture_wp_mail( bool $send_result = true ): array {
+ $captures = array();
+
+ $capture = static function ( $short_circuit, $atts ) use ( &$captures, $send_result ) {
+ unset( $short_circuit );
+ $captures[] = is_array( $atts ) ? $atts : array();
+ return $send_result;
+ };
+
+ add_filter( 'pre_wp_mail', $capture, 10, 2 );
+
+ $remove = static function () use ( $capture ) {
+ remove_filter( 'pre_wp_mail', $capture, 10 );
+ };
+
+ return array(
+ 'captures' => &$captures,
+ 'remove' => $remove,
+ );
+ }
+
+ /**
+ * @testdox Successful exchange dispatches a sign-in notification email to the user that minted the token.
+ */
+ public function test_exchange_token_dispatches_sign_in_notification_email(): void {
+ $capture = $this->capture_wp_mail();
+
+ try {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'os_version' => '17.5',
+ 'model' => 'iPhone 15',
+ 'app_version' => '24.7.0',
+ )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+
+ $this->assertCount( 1, $capture['captures'], 'Exactly one email should be sent on a successful exchange.' );
+ $mail = $capture['captures'][0];
+
+ $admin_user = get_userdata( $this->admin_id );
+ $this->assertSame( $admin_user->user_email, $mail['to'] );
+
+ $this->assertStringContainsString(
+ get_bloginfo( 'name' ),
+ (string) $mail['subject'],
+ 'Subject should reference the site name so a merchant managing multiple stores can disambiguate.'
+ );
+
+ $body = (string) $mail['message'];
+ $this->assertStringContainsString( 'iPhone 15', $body, 'Email body should surface the device model.' );
+ $this->assertStringContainsString( 'iOS 17.5', $body, 'Email body should surface the OS version.' );
+ $this->assertStringContainsString( '24.7.0', $body, 'Email body should surface the app version.' );
+ $this->assertStringContainsString( 'application-passwords', $body, 'Email body should link to the AP management screen.' );
+ } finally {
+ $capture['remove']();
+ }//end try
+ }
+
+ /**
+ * @testdox The sign-in notification email can be suppressed via the woocommerce_qr_login_should_send_signin_email filter.
+ */
+ public function test_sign_in_notification_email_can_be_suppressed_via_filter(): void {
+ $capture = $this->capture_wp_mail();
+ $suppress = static fn () => false;
+ add_filter( 'woocommerce_qr_login_should_send_signin_email', $suppress );
+
+ try {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+
+ $this->assertCount(
+ 0,
+ $capture['captures'],
+ 'Filter returning false must suppress the email send entirely.'
+ );
+ } finally {
+ remove_filter( 'woocommerce_qr_login_should_send_signin_email', $suppress );
+ $capture['remove']();
+ }//end try
+ }
+
+ /**
+ * @testdox A wp_mail false return is logged without failing a successful exchange.
+ */
+ public function test_sign_in_notification_email_false_return_is_logged_without_blocking_exchange(): void {
+ $capture = $this->capture_wp_mail( false );
+ $logger = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();
+ $logger->expects( $this->once() )
+ ->method( 'warning' )
+ ->with(
+ $this->stringContains( 'QR sign-in notification email failed' ),
+ $this->callback(
+ static function ( $context ) {
+ return is_array( $context )
+ && isset( $context['source'] )
+ && 'mobile-app-qr-login' === $context['source'];
+ }
+ )
+ );
+
+ $logger_filter = static fn () => $logger;
+ add_filter( 'woocommerce_logging_class', $logger_filter );
+
+ try {
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'Android',
+ 'model' => 'Pixel 10',
+ )
+ );
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( 1, $capture['captures'], 'A failed mailer return should still prove the send was attempted.' );
+ } finally {
+ remove_filter( 'woocommerce_logging_class', $logger_filter );
+ $capture['remove']();
+ }//end try
+ }
+
+ // ---------------------------------------------------------------------
+ // Task 7 — number-matching state machine.
+ // ---------------------------------------------------------------------
+
+ /**
+ * @testdox Scan endpoint transitions a pending token to scanned and returns a session_id + real_number.
+ */
+ public function test_scan_transitions_pending_to_scanned(): void {
+ $plaintext = $this->generate_token_as_admin();
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch_scan(
+ $plaintext,
+ array(
+ 'os' => 'Android',
+ 'model' => 'Pixel 10',
+ 'app_version' => '24.7.0',
+ )
+ );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertArrayHasKey( 'session_id', $data );
+ $this->assertNotEmpty( $data['session_id'] );
+ $this->assertArrayHasKey( 'real_number', $data );
+ $this->assertMatchesRegularExpression( '/^\d{3}$/', (string) $data['real_number'], 'Real number must be 3 digits, zero-padded.' );
+ $this->assertArrayHasKey( 'expires_in', $data );
+ $this->assertSame( MobileAppQRLogin::CHALLENGE_TTL_SECONDS, $data['expires_in'] );
+
+ // Underlying transient is now in the scanned state with the device payload threaded through.
+ $record = get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . hash( 'sha256', $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $record['state'] );
+ $this->assertSame( 'Pixel 10', $record['challenge']['device']['model'] ?? null );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SCAN, '203.0.113.10' )
+ );
+ $this->assertFalse( get_option( $this->token_scan_claim_key( $plaintext ), false ) );
+ }
+
+ /**
+ * @testdox Scan endpoint caps the challenge window to the original token lifetime.
+ */
+ public function test_scan_caps_challenge_window_to_remaining_token_lifetime(): void {
+ $plaintext = $this->generate_token_as_admin();
+ $transient_key = $this->token_transient_key( $plaintext );
+ $record = get_transient( $transient_key );
+ $expires_at = time() + 20;
+
+ $this->assertIsArray( $record );
+ $record['expires_at'] = $expires_at;
+ set_transient( $transient_key, $record, 20 );
+
+ wp_set_current_user( 0 );
+ $response = $this->dispatch_scan( $plaintext );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertGreaterThan( 0, $data['expires_in'] );
+ $this->assertLessThanOrEqual( 20, $data['expires_in'] );
+
+ $record = get_transient( $transient_key );
+ $this->assertIsArray( $record );
+ $this->assertSame( $expires_at, $record['challenge']['expires_at'] );
+ }
+
+ /**
+ * @testdox Scan endpoint returns 426 Upgrade Required when the mobile app omits the supports_number_matching capability flag.
+ */
+ public function test_scan_rejects_when_capability_flag_missing(): void {
+ $plaintext = $this->generate_token_as_admin();
+ wp_set_current_user( 0 );
+
+ // Simulate a pre-Task-7 mobile app that doesn't send the flag.
+ $response = $this->dispatch_scan( $plaintext, null, false );
+
+ $this->assertSame( 426, $response->get_status() );
+ $this->assertSame( 'mobile_app_update_required', $response->get_data()['code'] );
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SCAN, '203.0.113.10' )
+ );
+
+ // Token state must remain pending — a legacy scan must not mutate the record.
+ $record = get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . hash( 'sha256', $plaintext ) );
+ $this->assertSame( MobileAppQRLogin::STATE_PENDING, $record['state'] );
+ }
+
+ /**
+ * @testdox Scan endpoint rejects device payloads that do not include a model or OS identity.
+ */
+ public function test_scan_requires_device_identity(): void {
+ $plaintext = $this->generate_token_as_admin();
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch_scan(
+ $plaintext,
+ array( 'app_version' => '24.7.0' )
+ );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'invalid_device', $response->get_data()['code'] );
+
+ $record = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_PENDING, $record['state'] );
+ $this->assertFalse( get_option( $this->token_scan_claim_key( $plaintext ), false ) );
+ }
+
+ /**
+ * @testdox Scan endpoint rate-limits invalid random tokens without consuming the valid scan bucket.
+ */
+ public function test_scan_invalid_token_does_not_consume_scan_rate_limit(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch_scan( 'not-a-real-token' );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'invalid_token', $response->get_data()['code'] );
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SCAN, '203.0.113.10' )
+ );
+ $row = $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_INVALID_SCAN, '203.0.113.10' );
+ $this->assertNotNull( $row );
+ $this->assertSame( MobileAppQRLogin::MAX_INVALID_SCAN_ATTEMPTS - 1, (int) $row->rate_limit_remaining );
+ }
+
+ /**
+ * @testdox Scan endpoint returns 409 when called on a token that was already scanned.
+ */
+ public function test_scan_rejects_already_scanned_token(): void {
+ $plaintext = $this->generate_token_as_admin();
+ wp_set_current_user( 0 );
+ $this->assertSame( 200, $this->dispatch_scan( $plaintext )->get_status() );
+
+ // A second scan must fail — once scanned, only /approve can move the state.
+ $response = $this->dispatch_scan( $plaintext );
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'qr_login_already_scanned', $response->get_data()['code'] );
+ }
+
+ /**
+ * @testdox Approve endpoint mints an exchange_grant when the merchant taps the correct number.
+ */
+ public function test_approve_correct_choice_marks_approved_and_emits_grant(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_APPROVED, $response->get_data()['state'] );
+
+ $record = get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . hash( 'sha256', $plaintext ) );
+ $this->assertSame( MobileAppQRLogin::STATE_APPROVED, $record['state'] );
+ $this->assertNotEmpty( $record['exchange_grant'] );
+ $this->assertSame( MobileAppQRLogin::EXCHANGE_GRANT_BYTES * 2, strlen( (string) $record['exchange_grant'] ), 'Grant should be hex-encoded random_bytes — 2 chars per byte.' );
+ }
+
+ /**
+ * @testdox Approve returns 410 with no grant when the token expires between scan and tap.
+ */
+ public function test_approve_returns_410_when_token_expired_after_scan(): void {
+ $plaintext = $this->generate_token_as_admin();
+ $transient_key = $this->token_transient_key( $plaintext );
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ // Simulate the underlying token lapsing after the scan but before the
+ // merchant taps: push expires_at into the past while keeping the
+ // transient itself readable so approve re-reads the lapsed record.
+ $record = get_transient( $transient_key );
+ $this->assertIsArray( $record );
+ $record['expires_at'] = time() - 1;
+ set_transient( $transient_key, $record, 60 );
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_approve( $plaintext, (string) $scan_data['real_number'] );
+
+ $this->assertSame( 410, $response->get_status() );
+ $this->assertSame( 'qr_login_expired', $response->get_data()['code'] );
+
+ // Terminal expired state, and — even though the tapped number was
+ // correct — no exchange_grant may ever be minted.
+ $record = get_transient( $transient_key );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_EXPIRED, $record['state'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $record, 'An expired challenge must never mint an exchange grant.' );
+ }
+
+ /**
+ * @testdox Approve endpoint terminates the session with rejected when the merchant taps a wrong number.
+ */
+ public function test_approve_wrong_choice_marks_rejected(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ // Pick something that definitely isn't the real number — distractors
+ // are guaranteed to differ from the real one by ≥ 100, so subtracting
+ // 100 from the real (mod 1000) gives us a guaranteed-wrong value.
+ $wrong = str_pad( (string) ( ( ( (int) $scan_data['real_number'] ) + 500 ) % 1000 ), 3, '0', STR_PAD_LEFT );
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_approve( $plaintext, $wrong );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_REJECTED, $response->get_data()['state'] );
+
+ // Token transient is now in terminal rejected state — no exchange_grant ever issued.
+ $record = get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . hash( 'sha256', $plaintext ) );
+ $this->assertSame( MobileAppQRLogin::STATE_REJECTED, $record['state'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $record, 'Wrong-pick must never mint a grant — that is the entire point of the security guarantee.' );
+ }
+
+ /**
+ * @testdox Approve endpoint rejects another user trying to approve someone else's scanned token.
+ */
+ public function test_approve_rejects_other_user(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ // A second administrator who is also `manage_woocommerce`-capable
+ // must still be rejected — this is not "permission denied", it's
+ // "this token doesn't belong to you, full stop".
+ $other_admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ try {
+ wp_set_current_user( $other_admin_id );
+ $response = $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+
+ $this->assertSame( 401, $response->get_status() );
+ $this->assertSame( 'invalid_token', $response->get_data()['code'] );
+
+ // Even with the right number, a stranger must not move the token to approved.
+ $record = get_transient( MobileAppQRLogin::TOKEN_TRANSIENT_PREFIX . hash( 'sha256', $plaintext ) );
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $record['state'] );
+ } finally {
+ wp_delete_user( $other_admin_id );
+ }
+ }
+
+ /**
+ * @testdox Exchange endpoint returns 412 when the QR session has not been approved (no number-match step completed).
+ */
+ public function test_exchange_requires_approved_state(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $this->dispatch_scan( $plaintext );
+ // Note: deliberately skipping /approve — token is still in scanned, not approved.
+
+ $response = $this->dispatch_exchange( $plaintext, 'any-grant-here-doesnt-matter' );
+
+ $this->assertSame( 412, $response->get_status() );
+ $this->assertSame( 'qr_login_not_approved', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ), 'No AP must be issued without approval.' );
+
+ $record = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $record['state'] );
+ }
+
+ /**
+ * @testdox Exchange endpoint returns 412 when the merchant skipped /qr-login-scan entirely (token still in pending state).
+ */
+ public function test_exchange_rejects_token_that_skipped_scan(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ // Skip /scan and /approve entirely — token stays in pending. The
+ // state-machine guard collapses both this and the "scanned but not
+ // approved" case into a single 412 qr_login_not_approved response.
+ wp_set_current_user( 0 );
+ $response = $this->dispatch_exchange( $plaintext, 'irrelevant-grant' );
+
+ $this->assertSame( 412, $response->get_status() );
+ $this->assertSame( 'qr_login_not_approved', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ), 'Skipping scan must not be a path to mint an AP.' );
+
+ $record = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_PENDING, $record['state'] );
+ }
+
+ /**
+ * @testdox Exchange endpoint returns 412 when the exchange_grant nonce does not match the one minted at approve time.
+ */
+ public function test_exchange_requires_valid_grant_nonce(): void {
+ $prep = $this->prepare_exchange_token();
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], str_repeat( 'a', strlen( $prep['exchange_grant'] ) ) );
+
+ $this->assertSame( 412, $response->get_status() );
+ $this->assertSame( 'invalid_exchange_grant', $response->get_data()['code'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ), 'Grant mismatch must not produce an AP.' );
+
+ $record = get_transient( $this->token_transient_key( $prep['plaintext'] ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_APPROVED, $record['state'] );
+ $this->assertSame( 1, $record['invalid_grant_attempts'] );
+
+ $retry = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $retry->get_status() );
+ $this->assertCount( 1, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+ }
+
+ /**
+ * @testdox Invalid exchange grants do not extend approved records beyond the original token lifetime.
+ */
+ public function test_invalid_exchange_grant_does_not_extend_approved_record_past_token_lifetime(): void {
+ $prep = $this->prepare_exchange_token();
+ $transient_key = $this->token_transient_key( $prep['plaintext'] );
+ $record = get_transient( $transient_key );
+ $expires_at = time() + 20;
+
+ $this->assertIsArray( $record );
+ $record['expires_at'] = $expires_at;
+ set_transient( $transient_key, $record, 20 );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], str_repeat( 'b', strlen( $prep['exchange_grant'] ) ) );
+
+ $this->assertSame( 412, $response->get_status() );
+ $this->assertSame( 'invalid_exchange_grant', $response->get_data()['code'] );
+
+ $timeout = get_option( '_transient_timeout_' . $transient_key );
+ $this->assertNotFalse( $timeout, 'Token transient should keep a timeout after the failed exchange.' );
+ $this->assertLessThanOrEqual(
+ $expires_at,
+ (int) $timeout,
+ 'Failed exchange must not extend the approved record beyond token expiry.'
+ );
+ }
+
+ /**
+ * @testdox Repeated invalid exchange grants terminally reject the token after the threshold.
+ */
+ public function test_exchange_rejects_after_invalid_grant_threshold(): void {
+ $prep = $this->prepare_exchange_token();
+
+ for ( $i = 1; $i <= MobileAppQRLogin::MAX_INVALID_GRANT_ATTEMPTS; $i++ ) {
+ $response = $this->dispatch_exchange(
+ $prep['plaintext'],
+ str_repeat( (string) $i, strlen( $prep['exchange_grant'] ) )
+ );
+ $this->assertSame( 412, $response->get_status() );
+ $this->assertSame( 'invalid_exchange_grant', $response->get_data()['code'] );
+ }
+
+ $record = get_transient( $this->token_transient_key( $prep['plaintext'] ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_REJECTED, $record['state'] );
+ $this->assertSame( MobileAppQRLogin::MAX_INVALID_GRANT_ATTEMPTS, $record['invalid_grant_attempts'] );
+ $this->assertCount( 0, WP_Application_Passwords::get_user_application_passwords( $this->admin_id ) );
+
+ $status = $this->dispatch_session_status( $prep['session_id'], $prep['plaintext'] );
+ $this->assertSame( 200, $status->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_REJECTED, $status->get_data()['state'] );
+ }
+
+ /**
+ * @testdox Status endpoint returns a 3-element shuffled candidate triple (real + 2 distractors) while in scanned state and never reveals which one is real.
+ */
+ public function test_status_endpoint_returns_shuffled_triple_in_scanned(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+ $real_number = (string) $scan_data['real_number'];
+
+ wp_set_current_user( $this->admin_id );
+ $response = $this->dispatch_status( $plaintext );
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $data['status'] );
+ $this->assertCount( 3, $data['numbers'] );
+ foreach ( $data['numbers'] as $candidate ) {
+ $this->assertMatchesRegularExpression( '/^\d{3}$/', (string) $candidate );
+ }
+ $this->assertContains( $real_number, $data['numbers'], 'The shuffled triple must include the real number.' );
+
+ // Status response must NOT expose any field that flags which one is real.
+ $flat = wp_json_encode( $data );
+ $this->assertStringNotContainsString( '"real"', (string) $flat, 'Status payload must not contain a `real` field — that would defeat the entire shoulder-surf protection.' );
+ }
+
+ /**
+ * @testdox Concurrent /qr-login-scan requests on the same token produce exactly one winner thanks to the atomic claim — the loser is rejected with `qr_login_already_scanned`, never silently overwriting the canonical record.
+ */
+ public function test_scan_atomic_claim_rejects_concurrent_second_scan(): void {
+ $plaintext = $this->generate_token_as_admin();
+ $token_hash = hash( 'sha256', $plaintext );
+
+ // Pre-register the scan claim option to simulate a concurrent worker
+ // that has already grabbed the mutex but not yet completed its
+ // state-mutation. add_option() is atomic; the second worker (this
+ // test) must observe the existing key and bail out.
+ $claim_key = MobileAppQRLogin::SCAN_CLAIM_OPTION_PREFIX . $token_hash;
+ $claim_expires_at = (string) ( time() + 60 );
+ add_option( $claim_key, $claim_expires_at, '', false );
+
+ wp_set_current_user( 0 );
+ $response = $this->dispatch_scan( $plaintext );
+
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'qr_login_already_scanned', $response->get_data()['code'] );
+
+ // The token record must remain in pending — the rejected scan should
+ // not have mutated it. Without the claim, this assertion would fail
+ // because the second worker would have written its own challenge.
+ $record = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_PENDING, $record['state'] );
+ $this->assertSame(
+ $claim_expires_at,
+ get_option( $claim_key ),
+ 'A rejected second scan must not release another request\'s claim.'
+ );
+
+ delete_option( $claim_key );
+ }
+
+ /**
+ * @testdox Stale claim cleanup does not delete a fresh claim written after the stale value was observed.
+ */
+ public function test_stale_claim_cleanup_does_not_delete_fresh_replacement_claim(): void {
+ $claim_key = $this->token_claim_key( 'race-token' );
+ $stale_claim_value = (string) ( time() - 60 );
+ $fresh_claim_value = (string) ( time() + 60 );
+
+ $this->assertTrue( add_option( $claim_key, $stale_claim_value, '', false ) );
+ $this->assertTrue( update_option( $claim_key, $fresh_claim_value, false ) );
+
+ $controller = new MobileAppQRLogin();
+ $reflection = new \ReflectionClass( $controller );
+ $method = $reflection->getMethod( 'delete_claim_if_value_matches' );
+ $method->setAccessible( true );
+
+ $this->assertFalse( $method->invoke( $controller, $claim_key, $stale_claim_value ) );
+ $this->assertSame( $fresh_claim_value, get_option( $claim_key ) );
+
+ delete_option( $claim_key );
+ }
+
+ /**
+ * @testdox Concurrent /qr-login-approve requests on the same scanned token produce exactly one winner thanks to the atomic claim.
+ */
+ public function test_approve_atomic_claim_rejects_concurrent_second_approval(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ wp_set_current_user( $this->admin_id );
+
+ // Pre-register the approval claim option to simulate a concurrent
+ // worker that has already grabbed the mutex but not yet completed the
+ // scanned -> approved/rejected transition. The second worker must
+ // observe the existing key and bail out without mutating the record.
+ $claim_key = $this->token_approve_claim_key( $plaintext );
+ $claim_expires_at = (string) ( time() + 60 );
+ add_option( $claim_key, $claim_expires_at, '', false );
+
+ $response = $this->dispatch_approve( $plaintext, (string) $scan_data['real_number'] );
+
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'qr_login_approval_in_progress', $response->get_data()['code'] );
+
+ $record = get_transient( $this->token_transient_key( $plaintext ) );
+ $this->assertIsArray( $record );
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $record['state'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $record );
+ $this->assertSame(
+ $claim_expires_at,
+ get_option( $claim_key ),
+ 'A rejected concurrent approval must not release another request\'s claim.'
+ );
+
+ delete_option( $claim_key );
+ }
+
+ /**
+ * @testdox QR login endpoints introduced after the base PR use wc_rate_limits buckets instead of transient rate rows.
+ */
+ public function test_later_qr_endpoint_rate_limits_use_wc_rate_limits(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( $this->admin_id );
+ $this->assertSame( 200, $this->dispatch_status( $plaintext )->get_status() );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_STATUS, (string) $this->admin_id )
+ );
+
+ $this->assertSame( 404, $this->dispatch_revoke( 'missing-uuid' )->get_status() );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_REVOKE, (string) $this->admin_id )
+ );
+
+ wp_set_current_user( 0 );
+ $scan_response = $this->dispatch_scan( $plaintext );
+ $this->assertSame( 200, $scan_response->get_status() );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SCAN, '203.0.113.10' )
+ );
+
+ wp_set_current_user( $this->admin_id );
+ $this->assertSame(
+ 200,
+ $this->dispatch_approve( $plaintext, $scan_response->get_data()['real_number'] )->get_status()
+ );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_APPROVE, (string) $this->admin_id )
+ );
+
+ wp_set_current_user( 0 );
+ $this->assertSame(
+ 200,
+ $this->dispatch_session_status( $scan_response->get_data()['session_id'], $plaintext )->get_status()
+ );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row(
+ QRLoginRateLimits::BUCKET_SESSION_STATUS,
+ $scan_response->get_data()['session_id']
+ )
+ );
+
+ $this->assertSame( 0, $this->get_qr_login_rate_limit_transient_count() );
+ }
+
+ /**
+ * @testdox Session-status endpoint refuses to return any state without proof of token knowledge — a session_id alone yields opaque `expired`.
+ */
+ public function test_session_status_requires_token_hash_proof(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ // Approve the match server-side so a successful poll *would* otherwise
+ // return the grant. The point of this test is that without the
+ // token_hash, even an approved session can't be polled.
+ wp_set_current_user( $this->admin_id );
+ $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+ wp_set_current_user( 0 );
+
+ // No token_hash → opaque expired (we never confirm the session_id is real).
+ $response = $this->dispatch_session_status( $scan_data['session_id'] );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame(
+ MobileAppQRLogin::STATE_EXPIRED,
+ $response->get_data()['state']
+ );
+ $this->assertArrayNotHasKey( 'exchange_grant', $response->get_data() );
+
+ // Wrong token_hash → same opaque expired.
+ $response = $this->dispatch_session_status( $scan_data['session_id'], 'a-different-token' );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_EXPIRED, $response->get_data()['state'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $response->get_data() );
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SESSION_STATUS, $scan_data['session_id'] )
+ );
+
+ // Correct token_hash → grant delivered.
+ $response = $this->dispatch_session_status( $scan_data['session_id'], $plaintext );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_APPROVED, $response->get_data()['state'] );
+ $this->assertNotEmpty( $response->get_data()['exchange_grant'] );
+ $this->assertNotNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SESSION_STATUS, $scan_data['session_id'] )
+ );
+ }
+
+ /**
+ * @testdox Session-status endpoint requires HTTPS before it can return an exchange grant.
+ */
+ public function test_session_status_requires_https(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ wp_set_current_user( $this->admin_id );
+ $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+
+ wp_set_current_user( 0 );
+ $this->force_https( false );
+ $response = $this->dispatch_session_status( $scan_data['session_id'], $plaintext );
+
+ $this->assertSame( 403, $response->get_status() );
+ $this->assertSame( 'ssl_required', $response->get_data()['code'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $response->get_data() );
+ }
+
+ /**
+ * @testdox Session-status endpoint does not create rate-limit rows for random missing sessions.
+ */
+ public function test_session_status_missing_session_does_not_consume_rate_limit(): void {
+ wp_set_current_user( 0 );
+
+ $response = $this->dispatch_session_status( 'missing-session-id', 'not-a-real-token' );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_EXPIRED, $response->get_data()['state'] );
+ $this->assertNull(
+ $this->get_qr_login_rate_limit_row( QRLoginRateLimits::BUCKET_SESSION_STATUS, 'missing-session-id' )
+ );
+ }
+
+ /**
+ * @testdox Session-status endpoint returns the exchange_grant once the merchant has approved the number-match.
+ */
+ public function test_session_status_returns_grant_when_approved(): void {
+ $plaintext = $this->generate_token_as_admin();
+
+ wp_set_current_user( 0 );
+ $scan_data = $this->dispatch_scan( $plaintext )->get_data();
+
+ // Before approval — session-status returns scanned, no grant.
+ $pre = $this->dispatch_session_status( $scan_data['session_id'], $plaintext );
+ $this->assertSame( 200, $pre->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_SCANNED, $pre->get_data()['state'] );
+ $this->assertArrayNotHasKey( 'exchange_grant', $pre->get_data() );
+
+ // Approve.
+ wp_set_current_user( $this->admin_id );
+ $this->dispatch_approve( $plaintext, $scan_data['real_number'] );
+
+ // After approval — session-status returns approved + exchange_grant.
+ wp_set_current_user( 0 );
+ $post = $this->dispatch_session_status( $scan_data['session_id'], $plaintext );
+ $this->assertSame( 200, $post->get_status() );
+ $this->assertSame( MobileAppQRLogin::STATE_APPROVED, $post->get_data()['state'] );
+ $this->assertNotEmpty( $post->get_data()['exchange_grant'] );
+ }
+
+ // -----------------------------------------------------------------------
+ // Availability endpoint (`/qr-login-availability`).
+ // -----------------------------------------------------------------------
+
+ /**
+ * Issue a GET to the availability endpoint.
+ *
+ * @return \WP_REST_Response
+ */
+ private function dispatch_availability(): \WP_REST_Response {
+ $request = new WP_REST_Request( 'GET', self::AVAILABILITY_ENDPOINT );
+ return $this->server->dispatch( $request );
+ }
+
+ /**
+ * @testdox Availability endpoint reports `available: true` when HTTPS + application passwords are both ready.
+ */
+ public function test_availability_reports_available_when_https_and_application_passwords_are_ready(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_availability();
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertTrue( $data['available'] );
+ $this->assertNull( $data['reason'] );
+ }
+
+ /**
+ * @testdox Availability endpoint reports `https_required` when the site is served over plain HTTP.
+ */
+ public function test_availability_reports_https_required_when_site_is_not_secure(): void {
+ wp_set_current_user( $this->admin_id );
+ $this->force_https( false );
+ $this->force_site_url( 'http://example.org' );
+
+ $response = $this->dispatch_availability();
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertFalse( $data['available'] );
+ $this->assertSame(
+ MobileAppQRLogin::AVAILABILITY_REASON_HTTPS_REQUIRED,
+ $data['reason']
+ );
+ }
+
+ /**
+ * @testdox Availability endpoint mirrors the token endpoint when the request is HTTP but siteurl is HTTPS.
+ */
+ public function test_availability_reports_https_required_when_request_is_not_secure(): void {
+ wp_set_current_user( $this->admin_id );
+ $this->force_https( false );
+ $this->force_site_url( 'https://example.org' );
+
+ $response = $this->dispatch_availability();
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertFalse( $data['available'] );
+ $this->assertSame(
+ MobileAppQRLogin::AVAILABILITY_REASON_HTTPS_REQUIRED,
+ $data['reason']
+ );
+ }
+
+ /**
+ * @testdox Availability endpoint reports `application_passwords_disabled_by_filter` when the WP filter returns false.
+ */
+ public function test_availability_reports_filter_when_application_passwords_filter_disables_them(): void {
+ wp_set_current_user( $this->admin_id );
+
+ add_filter( 'wp_is_application_passwords_available', '__return_false' );
+ try {
+ $response = $this->dispatch_availability();
+ } finally {
+ remove_filter( 'wp_is_application_passwords_available', '__return_false' );
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertFalse( $data['available'] );
+ $this->assertSame(
+ MobileAppQRLogin::AVAILABILITY_REASON_AP_DISABLED_BY_FILTER,
+ $data['reason']
+ );
+ }
+
+ /**
+ * @testdox Availability endpoint rejects subscribers with the same capability gate as the token endpoint.
+ */
+ public function test_availability_rejects_subscriber(): void {
+ wp_set_current_user( $this->subscriber_id );
+
+ $response = $this->dispatch_availability();
+
+ $this->assertSame( rest_authorization_required_code(), $response->get_status() );
+ }
+
+ /**
+ * @testdox Availability endpoint emits no-cache headers so a stale unavailable response cannot be pinned.
+ */
+ public function test_availability_emits_nocache_headers(): void {
+ wp_set_current_user( $this->admin_id );
+
+ $response = $this->dispatch_availability();
+
+ $this->assertSame( 200, $response->get_status() );
+ $headers = array_change_key_case( $response->get_headers(), CASE_LOWER );
+ $this->assertArrayHasKey( 'cache-control', $headers );
+ $this->assertStringContainsStringIgnoringCase(
+ 'no-cache',
+ (string) $headers['cache-control']
+ );
+ }
+
+ /**
+ * @testdox Distractor distinctness invariant — across many challenge generations, every triple has all values ≥ 100 apart.
+ */
+ public function test_distractor_distinctness_invariant(): void {
+ // Direct invocation via reflection so we don't have to fire 200
+ // /qr-login-scan requests (which would hit the rate limiter at 10/IP).
+ $controller = new MobileAppQRLogin();
+ $reflection = new \ReflectionClass( $controller );
+ $method = $reflection->getMethod( 'generate_challenge_numbers' );
+ $method->setAccessible( true );
+
+ for ( $i = 0; $i < 200; $i++ ) {
+ $challenge = $method->invoke( $controller );
+ $values = array_map( 'intval', array_merge( array( $challenge['real'] ), $challenge['distractors'] ) );
+ $this->assertCount( 3, $values, 'Challenge generator must always return 1 real + 2 distractors.' );
+
+ $count = count( $values );
+ for ( $a = 0; $a < $count; $a++ ) {
+ for ( $b = $a + 1; $b < $count; $b++ ) {
+ $this->assertGreaterThanOrEqual(
+ 100,
+ abs( $values[ $a ] - $values[ $b ] ),
+ sprintf( 'Iteration #%d: %d vs %d are < 100 apart — would let a partial-read leak fingerprint the real number.', $i, $values[ $a ], $values[ $b ] )
+ );
+ }
+ }
+ }
+ }
+}