Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5af561a
Add a reusable activation URL API for PHP and JS
dave-green-uk Jul 22, 2026
af49f7d
Build frontend assets
dave-green-uk Jul 22, 2026
d97364c
Fix the default activation redirect landing on a WordPress error page
dave-green-uk Jul 22, 2026
7de8400
Correct the enqueue timing guidance for the activation script
dave-green-uk Jul 22, 2026
d3fc2b1
Correct the rationale for the canonical redirect URL
dave-green-uk Jul 22, 2026
bd1a42a
Use US spelling in the activation URL test docblock
dave-green-uk Jul 22, 2026
f4b36ce
Refresh licensing data when the portal returns a user to the site
dave-green-uk Jul 22, 2026
d78dad3
Fix fallout from moving the refresh out of the admin page
dave-green-uk Jul 22, 2026
1c8efec
Realign the constructor docblock after dropping a parameter
dave-green-uk Jul 22, 2026
0a4a279
Check the return tag before resolving the refresh handler
dave-green-uk Jul 22, 2026
dc9f24f
Expose activation URLs through stable global functions
dave-green-uk Jul 24, 2026
006b85b
Align the activation-URL doc tables to satisfy markdownlint
dave-green-uk Jul 24, 2026
081c461
Address review feedback on the activation URL API
dave-green-uk Jul 27, 2026
189b9c1
Stop suppressing exit() in the test suite
dave-green-uk Jul 27, 2026
cf6ed65
Fix the redirect stand-in's class scope and a docblock tag
dave-green-uk Jul 27, 2026
a5f52f1
Warn at runtime when the deprecated redirect handler is called
dave-green-uk Jul 27, 2026
7c0651b
Merge bucket/activation-flow-api into the activation URL API branch
dave-green-uk Jul 28, 2026
bc40333
Address the second review round on the activation URL API
dave-green-uk Jul 28, 2026
e203f94
Build frontend assets
dave-green-uk Jul 28, 2026
38455b8
Isolate the enqueue hook in the script dependency tests
dave-green-uk Jul 28, 2026
f981248
Return null rather than an empty string when there is no activation URL
dave-green-uk Jul 28, 2026
6ebaeef
Expose product license lookups so consumers need not touch internal c…
dave-green-uk Jul 30, 2026
e58fb2f
Drop the browser activation helper in favour of building URLs in PHP
dave-green-uk Aug 12, 2026
11d89ed
Let the return handler decide whether a request is a return trip
dave-green-uk Aug 12, 2026
ae32efd
Cover the compiled asset resolver with unit tests
dave-green-uk Aug 12, 2026
4067013
Drop the changelog note for an argument that never shipped
dave-green-uk Aug 12, 2026
2c50995
Qualify the base activation URL function as product activation
dave-green-uk Aug 12, 2026
f8df048
Fix two spellings cspell rejects
dave-green-uk Aug 12, 2026
4735e06
Inject the catalog repository rather than service-locating it
dave-green-uk Aug 12, 2026
a52253b
remove LiquidWeb\Harbor\Utils\Assets class
pramodjodhani Aug 14, 2026
d6ebc7b
Merge branch 'smtnc-1844-harbor-expose-a-reusable-activation-url-api-…
pramodjodhani Aug 14, 2026
6375aac
remove AssetsTest.php
pramodjodhani Aug 14, 2026
96ade84
repalce maybe_refresh_after_activation with closure/anon funciton
pramodjodhani Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/smtnc-1844-activation-return-refresh.yaml
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
4 changes: 4 additions & 0 deletions changelog/smtnc-1844-activation-url-api.yaml
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
4 changes: 4 additions & 0 deletions changelog/smtnc-1844-product-license-lookups.yaml
Comment thread
d4mation marked this conversation as resolved.
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
184 changes: 184 additions & 0 deletions docs/guides/activation-urls.md
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.
Comment thread
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 );
}
```
57 changes: 57 additions & 0 deletions src/Harbor/API/Functions/Global_Function_Registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 $tier so it might be okay to let it be nullable.

Although how do you plan on actually getting the tiers from inside a plugin to even pass to this function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

Screenshot 2026-08-12 154929

But with the tier, they get taken straight to it:

Screenshot 2026-08-12 155002

I feel this is a better experience, but no so strongly that it's a hill I want to die on.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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,
Expand Down
30 changes: 17 additions & 13 deletions src/Harbor/API/Functions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,23 @@ Strauss rewrites class references at parse time. `License_Repository::class` ins

`src/Harbor/global-functions.php` exposes the public functions. Plugin consumers call these:

| Function | What it does |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `lw_harbor_has_unified_license_key()` | Whether any unified license key is stored locally (no API call) |
| `lw_harbor_get_unified_license_key()` | Returns the unified license key string, or null if not found |
| `lw_harbor_is_product_license_active( $product )` | Whether a product slug has `validation_status: valid` in the cached catalog |
| `lw_harbor_is_feature_enabled( $slug )` | Whether a feature is in the catalog AND currently enabled/active |
| `lw_harbor_is_feature_available( $slug )` | Whether a feature exists in the catalog, regardless of enabled state |
| `lw_harbor_get_license_page_url()` | Returns the admin URL for the Harbor Feature Manager page |
| `lw_harbor_get_licensed_domain()` | Returns the domain Harbor uses for licensing on the current site |
| `lw_harbor_register_submenu( $parent_slug )` | Appends a Licensing submenu item under a plugin's top-level admin menu |
| `lw_harbor_display_legacy_license_page_notice( $product_name = '' )` | Renders an info notice on a plugin's legacy license page pointing users to the unified system |
| `lw_harbor_has_consent()` | Whether the site owner has opted in to external Liquid Web API communications |
| `lw_harbor_refresh_catalog()` | Force a synchronous re-fetch of the product catalog from the Commerce Portal API |
| Function | What it does |
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `lw_harbor_has_unified_license_key()` | Whether any unified license key is stored locally (no API call) |
| `lw_harbor_get_unified_license_key()` | Returns the unified license key string, or null if not found |
| `lw_harbor_is_product_license_active( $product )` | Whether a product slug has `validation_status: valid` in the cached catalog |
| `lw_harbor_is_product_licensed( $product )` | Whether the stored license covers a product at all, activated here or not |
| `lw_harbor_get_product_tier( $product )` | The tier a product is licensed at, or null when absent or licensed at several |
| `lw_harbor_is_feature_enabled( $slug )` | Whether a feature is in the catalog AND currently enabled/active |
| `lw_harbor_is_feature_available( $slug )` | Whether a feature exists in the catalog, regardless of enabled state |
| `lw_harbor_get_license_page_url()` | Returns the admin URL for the Harbor Feature Manager page |
| `lw_harbor_get_product_activation_base_url( $redirect_url = null )` | The unscoped portal activation URL, or null when unavailable |
| `lw_harbor_get_product_activation_url( $slug, $tier = null, $redirect = null )` | The activation URL scoped to a product via `sku`, and to a tier when one is given, or null when unavailable |
| `lw_harbor_get_licensed_domain()` | Returns the domain Harbor uses for licensing on the current site |
| `lw_harbor_register_submenu( $parent_slug )` | Appends a Licensing submenu item under a plugin's top-level admin menu |
| `lw_harbor_display_legacy_license_page_notice( $product_name = '' )` | Renders an info notice on a plugin's legacy license page pointing users to the unified system |
| `lw_harbor_has_consent()` | Whether the site owner has opted in to external Liquid Web API communications |
| `lw_harbor_refresh_catalog()` | Force a synchronous re-fetch of the product catalog from the Commerce Portal API |

Each function looks up the registered callback and delegates, returning `false` if no callback is registered yet:

Expand Down
Loading
Loading