-
Notifications
You must be signed in to change notification settings - Fork 0
Add a reusable activation URL API #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: bucket/activation-flow-api
Are you sure you want to change the base?
Changes from all commits
5af561a
af49f7d
d97364c
7de8400
d3fc2b1
bd1a42a
f4b36ce
d78dad3
1c8efec
0a4a279
dc9f24f
006b85b
081c461
189b9c1
cf6ed65
a5f52f1
7c0651b
bc40333
e203f94
38455b8
f981248
6ebaeef
e58fb2f
11d89ed
ae32efd
4067013
2c50995
f8df048
4735e06
a52253b
d6ebc7b
6375aac
96ade84
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| significance: minor | ||
| type: feature | ||
| entry: Licensing data is now refreshed automatically when the Liquid Web portal returns a user to the site after activating, so screens gated on license state are correct on arrival. Host plugins need no code for this; sending the user through an activation URL is the whole opt-in. This supersedes the Software Manager page's ?refresh=auto handler, which only covered that one screen; Feature_Manager_Page::maybe_redirect_after_refresh() is deprecated and no longer hooked | ||
| timestamp: 2026-07-22T00:00:00.000Z |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| significance: minor | ||
| type: feature | ||
| entry: Added a reusable activation URL API. Host plugins can build Liquid Web portal activation URLs via the lw_harbor_get_product_activation_base_url() and lw_harbor_get_product_activation_url() global functions, and pass them to their own onboarding screens | ||
| timestamp: 2026-07-22T00:00:00.000Z |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| significance: minor | ||
| type: feature | ||
| entry: Added lw_harbor_is_product_licensed() and lw_harbor_get_product_tier() so host plugins can ask whether a license covers a product, and at which tier, without resolving Harbor's internal licensing classes from their own vendor-prefixed copy | ||
| timestamp: 2026-07-30T00:00:00.000Z |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| # Activation URLs | ||
|
|
||
| An activation URL sends the user to the Liquid Web portal with enough context to | ||
| activate a product against the current site. Harbor builds these URLs so host | ||
| plugins do not each reimplement the portal's query string. | ||
|
|
||
| Use this when you need an "Activate" button outside Harbor's own Software | ||
| Manager page — for example on a plugin's onboarding screen. | ||
|
|
||
| ## What the URL contains | ||
|
|
||
| ```text | ||
| {portal_base_url}/subscriptions/ | ||
| ?portal-referral=plugin | ||
| &redirect_url={where the portal returns the user} | ||
| &domain={this site's domain} | ||
| &sku={product_slug}:{tier} # only on product-scoped URLs | ||
| # the :{tier} half is omitted when unknown | ||
| ``` | ||
|
|
||
| `redirect_url` is percent-encoded, so its own query string does not leak into | ||
| the portal URL as separate params. `sku` is what lets the portal pre-select a | ||
| product and tier instead of dropping the user on an unfiltered list. Without a | ||
| tier the portal offers a picker limited to the activating domain, so a partial | ||
| `sku` narrows the choice rather than failing. | ||
|
|
||
| Harbor appends `lw-harbor-activated=1` to whatever return URL you supply. That | ||
| tag lives inside `redirect_url`, not at the top level — see below. | ||
|
|
||
| ## The return trip refreshes your data automatically | ||
|
|
||
| Licensing data is cached. Without a refresh, a user who has just activated in | ||
| the portal comes back to a screen that still believes they are unlicensed: the | ||
| Activate button is still there, the feature they paid for is still gated. | ||
|
|
||
| You do not have to handle this. Harbor tags every return URL it builds and | ||
| watches for that tag on any admin screen. On the way back it refreshes the | ||
| license products and the catalog, strips the tag, and redirects — all on | ||
| `admin_init`, before your page renders. By the time your code runs, | ||
| `License_Repository` is current. | ||
|
|
||
| Consequences worth knowing: | ||
|
|
||
| - **Read licensing state at render time**, not from something cached earlier in | ||
| the request. The refresh has already happened by then. | ||
| - **The URL the user lands on is not the one you supplied** — it briefly carries | ||
| `lw-harbor-activated=1`, then redirects to your clean URL. Anything that | ||
| fingerprints the query string should tolerate that. | ||
| - **Only one instance refreshes.** The handler is behind the same version | ||
| leadership check as the rest of Harbor, so four active plugins using Harbor make | ||
| one API call between them, not four. | ||
|
d4mation marked this conversation as resolved.
|
||
| - **It requires `manage_options`.** The tag rides on a URL your plugin owns, so | ||
| it can land on a screen with no capability check of its own. | ||
| - **Failures are logged, not surfaced.** If the refresh fails the user still | ||
| reaches your page, with stale data. They have just come back from activating | ||
| and are looking at your screen, not a licensing one, so an error notice there | ||
| would be noise they cannot act on. | ||
|
|
||
| ## From PHP | ||
|
|
||
| Call the global functions. Like the rest of Harbor's public API they resolve to | ||
| the highest-version Harbor copy on the site, so you always get the loaded | ||
| version's logic — do not build the URL from a Harbor class in your own bundled | ||
| copy, which may not be the one actually running. | ||
|
|
||
| ```php | ||
| // Product-scoped, returning the user to your onboarding screen. | ||
| $href = lw_harbor_get_product_activation_url( | ||
| 'kadence', | ||
| lw_harbor_get_product_tier( 'kadence' ), | ||
| add_query_arg( | ||
| [ | ||
| 'page' => 'kadence-onboarding', | ||
| 'step' => 2, | ||
| ], | ||
| admin_url( 'admin.php' ) | ||
| ) | ||
| ); | ||
| ``` | ||
|
|
||
| | Function | Returns | | ||
| | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ||
| | `lw_harbor_get_product_activation_base_url( ?string $redirect_url )` | The portal subscriptions URL with referral, redirect, and domain params | | ||
| | `lw_harbor_get_product_activation_url( string $slug, ?string $tier, ?string $redirect_url )` | The same, plus `sku={slug}` and `:{tier}` when a tier is given | | ||
| | `lw_harbor_is_product_licensed( string $slug )` | Whether the stored license covers the product at all, activated or not | | ||
| | `lw_harbor_get_product_tier( string $slug )` | The licensed tier, or `null` when absent or licensed at several | | ||
|
|
||
| The URL builders return `null` when no Harbor instance is active, or when the URL | ||
| could not be built — treat that as "hide the button". Omit `$redirect_url` to fall | ||
| back to Harbor's Software Manager page. Pass your own whenever the user started | ||
| somewhere else — otherwise they will not come back to where they were. | ||
|
|
||
| ```php | ||
| $href = lw_harbor_get_product_activation_base_url( $return_url ); | ||
|
|
||
| if ( null === $href ) { | ||
| return; // Nothing to offer. | ||
| } | ||
| ``` | ||
|
|
||
| ### Do not look the tier up yourself | ||
|
|
||
| `$tier` is optional, and `lw_harbor_get_product_tier()` is the supported way to | ||
| find one. Pass its result straight through, including when it is `null`: an | ||
| unscoped `sku` sends the user to the portal's product and tier picker, still | ||
| scoped to the activating domain, which is the right screen when the license | ||
| covers the product at more than one tier. | ||
|
|
||
| Reaching into `License_Repository` or `Product_Entry` from your own bundled copy | ||
| to read a tier is the thing this API exists to replace. Those classes are | ||
| Strauss-prefixed per plugin, and only the highest-version copy refreshes the | ||
| catalog — so you would be reading the leader's data with your own, possibly | ||
| older, code. | ||
|
|
||
| ```php | ||
| // Licensed but not yet activated here: the state worth prompting on. | ||
| if ( | ||
| lw_harbor_is_product_licensed( 'kadence' ) | ||
| && ! lw_harbor_is_product_license_active( 'kadence' ) | ||
| ) { | ||
| $href = lw_harbor_get_product_activation_url( | ||
| 'kadence', | ||
| lw_harbor_get_product_tier( 'kadence' ), | ||
| $return_url | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| ### Getting the return URL right | ||
|
|
||
| Prefer your page's canonical address — the parent it is actually registered | ||
| under: | ||
|
|
||
| | How your page is registered | Canonical return URL | | ||
| | ---------------------------------------------- | --------------------------------- | | ||
| | `add_menu_page()` (top level) | `admin.php?page={slug}` | | ||
| | `add_submenu_page( 'options-general.php', … )` | `options-general.php?page={slug}` | | ||
| | `add_submenu_page( 'tools.php', … )` | `tools.php?page={slug}` | | ||
|
|
||
| WordPress will resolve an `admin.php?page={slug}` URL to a submenu page anyway, | ||
| so this is a consistency preference rather than a correctness requirement. | ||
| `menu_page_url( 'your-slug', false )` returns the canonical form without | ||
| hardcoding the parent. | ||
|
|
||
| The examples below assume a top-level menu. | ||
|
|
||
| ## From JavaScript | ||
|
|
||
| Harbor does not ship a browser API for this. Build the URL in PHP and hand it to | ||
| your script, so the `sku` contract lives in exactly one place. | ||
|
|
||
| ```php | ||
| wp_localize_script( | ||
| 'kadence-onboarding', | ||
| 'kadenceOnboarding', | ||
| [ | ||
| 'activationUrl' => lw_harbor_get_product_activation_url( | ||
| 'kadence', | ||
| lw_harbor_get_product_tier( 'kadence' ), | ||
| menu_page_url( 'kadence-onboarding', false ) | ||
| ), | ||
| ] | ||
| ); | ||
| ``` | ||
|
|
||
| ```js | ||
| if ( kadenceOnboarding.activationUrl ) { | ||
| // safe to link to | ||
| } | ||
| ``` | ||
|
|
||
| The function returns `null` when no Harbor instance is active, so a falsy value | ||
| is your signal to hide the control rather than render a dead link. | ||
|
|
||
| When the tier is chosen in the browser, localize one URL per tier and pick | ||
| between them client-side: | ||
|
|
||
| ```php | ||
| $tiers = []; | ||
|
|
||
| foreach ( [ 'plus', 'pro' ] as $tier ) { | ||
| $tiers[ $tier ] = lw_harbor_get_product_activation_url( 'kadence', $tier, $return_url ); | ||
| } | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| use LiquidWeb\Harbor\Config; | ||
| use LiquidWeb\Harbor\Features\Manager; | ||
| use LiquidWeb\Harbor\Licensing\Repositories\License_Repository; | ||
| use LiquidWeb\Harbor\Portal\Activation\Url; | ||
| use LiquidWeb\Harbor\Portal\Catalog_Repository; | ||
| use LiquidWeb\Harbor\Site\Data; | ||
| use LiquidWeb\Harbor\Traits\With_Debugging; | ||
|
|
@@ -131,6 +132,62 @@ static function (): string { | |
| } | ||
| ); | ||
|
|
||
| \_lw_harbor_global_function_registry( | ||
| 'lw_harbor_get_product_activation_base_url', | ||
| $version, | ||
| static function ( ?string $redirect_url = null ): ?string { | ||
| try { | ||
| return Config::get_container()->get( Url::class )->get_base( $redirect_url ); | ||
| } catch ( Throwable $e ) { | ||
| self::debug_log_throwable( $e, 'Error building activation URL' ); | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| \_lw_harbor_global_function_registry( | ||
| 'lw_harbor_get_product_activation_url', | ||
| $version, | ||
| static function ( string $product_slug, ?string $tier = null, ?string $redirect_url = null ): ?string { | ||
| try { | ||
| return Config::get_container()->get( Url::class )->for_product( $product_slug, $tier, $redirect_url ); | ||
| } catch ( Throwable $e ) { | ||
| self::debug_log_throwable( $e, 'Error building product activation URL' ); | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| ); | ||
|
Comment on lines
+149
to
+161
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm pretty sure there is some default logic on the portal side to figure out the Although how do you plan on actually getting the tiers from inside a plugin to even pass to this function?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both covered. Tier is ?string $tier = null with tests for null and empty-string. For getting one, lw_harbor_get_product_tier() landed in 6ebaeef after you commented — it returns null when a license covers a product at several tiers, and you pass that straight through. I confirmed the portal handles a bare sku fine: still shows the customer the products on their subscription.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with @jonwaldstein. The portal is in the best position to answer what tiers are available, why do we need to pass it a suggestion, and add a new global function. I really don't like expanding this API unnecessarily.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @johnhooks This was for convinience/better UX more than anything. For example - without the tier, the customer lands on a page like this:
But with the tier, they get taken straight to it:
I feel this is a better experience, but no so strongly that it's a hill I want to die on.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can live with it. I still think it's a bit odd, but I understand the intend and why it would be useful. I would just prefer the LW Portal to handle receiving a request for product activation and intelligently recognizing the customer has multiple tiers and offer the options. |
||
|
|
||
| \_lw_harbor_global_function_registry( | ||
| 'lw_harbor_is_product_licensed', | ||
| $version, | ||
| static function ( string $product ): bool { | ||
| try { | ||
| return Config::get_container()->get( License_Repository::class )->has_product( $product ); | ||
| } catch ( Throwable $e ) { | ||
| self::debug_log_throwable( $e, 'Error checking whether a product is licensed' ); | ||
|
|
||
| return false; | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| \_lw_harbor_global_function_registry( | ||
| 'lw_harbor_get_product_tier', | ||
| $version, | ||
| static function ( string $product ): ?string { | ||
| try { | ||
| return Config::get_container()->get( License_Repository::class )->get_product_tier( $product ); | ||
| } catch ( Throwable $e ) { | ||
| self::debug_log_throwable( $e, 'Error reading product tier' ); | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| \_lw_harbor_global_function_registry( | ||
| 'lw_harbor_display_legacy_license_page_notice', | ||
| $version, | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.