diff --git a/assets/src/modules/admin/components/addon-card.js b/assets/src/modules/admin/components/addon-card.js index e52d7b9..f1778fe 100644 --- a/assets/src/modules/admin/components/addon-card.js +++ b/assets/src/modules/admin/components/addon-card.js @@ -7,15 +7,15 @@ * 1. An optional banner image (`CardMedia`). When the addon is * installed + active we also paint an "Active" pill in the * top-right corner of the banner. - * 2. A header carrying the addon title (and the "Active" pill as - * a fallback when there's no banner to overlay it on). - * 3. A description body — this is the flex grower so the footer - * stays at a fixed height regardless of description length. - * 4. A footer carrying the primary CTA (Manage license / Get it / - * Buy now) and a "Learn more" link. + * 2. A header carrying the addon title + the badge cluster. + * 3. A description body — the flex grower so the footer stays at + * a fixed height regardless of description length. + * 4. A footer carrying the primary CTA on the left and a "More + * details" link on the right. Layout matches the 404-to-301 + * plugin's addon cards so the two products feel like siblings. * * License management lives in a sibling modal — clicking - * "Manage license" fires `onManageLicense(addon)` so the modal can + * "Manage License" fires `onManageLicense(addon)` so the modal can * be hoisted to the tab root (keeps focus management + ARIA * announcements clean). * @@ -38,14 +38,14 @@ import BannerImage from './banner-image'; const AddonCard = ( { addon, onManageLicense } ) => { /* - * Purchase CTA — shown only when the addon isn't installed - * locally. Variant flips on `is_premium` so the paid items get - * the high-emphasis primary button while the free ones use the - * lower-key secondary button. + * Purchase CTA shown when the addon is NOT installed locally. + * Variant flips on `is_premium` so paid items get the + * high-emphasis primary button while free items use the + * secondary button. * * Link priority: the catalogue's marketing URL (`link`) first, - * then the project homepage (`homepage`) as a fallback for - * rows that ship without a dedicated buy URL. + * then the project homepage (`homepage`) as a fallback for rows + * that ship without a dedicated buy URL. */ const purchaseCta = ( @@ -166,6 +147,18 @@ const AddonCard = ( { addon, onManageLicense } ) => { purchaseCta ) } + { addon.homepage && ( + + + + ) } diff --git a/assets/src/modules/admin/components/force-logout-panel.js b/assets/src/modules/admin/components/force-logout-panel.js index 5120dae..d3432ac 100644 --- a/assets/src/modules/admin/components/force-logout-panel.js +++ b/assets/src/modules/admin/components/force-logout-panel.js @@ -16,14 +16,61 @@ import { useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { Button, + Notice, PanelBody, PanelRow, TextControl, } from '@wordpress/components'; import apiFetch from '@wordpress/api-fetch'; import { useDispatch } from '@wordpress/data'; +import { applyFilters } from '@wordpress/hooks'; import { store as noticesStore } from '@wordpress/notices'; +/** + * Default cross-sell Notice promoting the Active Sessions addon. + * + * Lives at the bottom of the Force Logout panel and is routed through + * the `loggedin.settings.force_logout.cross_sell` filter so the addon + * — once loaded on this same screen — returns `null` to hide it. + * + * The CTA is an inline text link in the body copy rather than the + * Notice `actions` button: the button forces the banner to a taller + * two-row layout, so a plain link keeps the notice compact. Matches + * the 404 to 301 plugin's cross-sell design. + * + * The Notice is non-dismissible: a dismissible CTA would persist its + * dismissed state in component memory only (no server round-trip), so + * it'd come straight back on the next page load. Better to render a + * stable banner the addon can swap out entirely. + */ +const DefaultCrossSell = () => ( + + +

+ + { __( + 'Need to log someone out without knowing who?', + 'loggedin' + ) } + +

+

+ { __( + 'Install the Active Sessions addon to browse every user with a live session, drill into each device they’re signed in from, and sign them out one at a time — or all at once.', + 'loggedin' + ) }{ ' ' } + + { __( 'Get Active Sessions', 'loggedin' ) } + +

+
+
+); + const ForceLogoutPanel = () => { // Local input state. Plain `useState` — the panel doesn't need // to share its value with anything else in the React tree. @@ -35,6 +82,18 @@ const ForceLogoutPanel = () => { const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + /* + * Cross-sell slot. Defaults to ; the Active + * Sessions addon returns `null` here to suppress the promo once + * installed. Kept as its own filter so future addons can replace + * the banner with a different recommendation without parent-side + * changes. + */ + const crossSell = applyFilters( + 'loggedin.settings.force_logout.cross_sell', + + ); + /** * Submit handler — POST the identifier and dispatch a snackbar * reflecting the result. @@ -116,6 +175,8 @@ const ForceLogoutPanel = () => { : __( 'Force Logout', 'loggedin' ) } + + { crossSell } ); }; diff --git a/assets/src/modules/admin/index.js b/assets/src/modules/admin/index.js index a68fb74..c7d45fc 100644 --- a/assets/src/modules/admin/index.js +++ b/assets/src/modules/admin/index.js @@ -10,12 +10,12 @@ * resolvers are scoped to the time it's on screen (no stale * subscriptions, no background polling for hidden tabs). */ -import { useState } from '@wordpress/element'; +import { useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Spinner } from '@wordpress/components'; import { Footer, Notices, PageBody, PageHeader, TabNav } from '../../common'; import useSettings from '../../hooks/use-settings'; -import tabs from './tabs'; +import { resolveTabs } from './tabs'; const AdminApp = () => { // We block the first render of any tab on the initial settings @@ -25,6 +25,11 @@ const AdminApp = () => { // when the user lands on the default Settings tab. const { hasLoaded } = useSettings(); + // Resolve the tab registry on mount so addon bundles have a + // chance to register via `loggedin.admin.tabs` before the filter + // is read. Memoised so the lookup doesn't run every render. + const tabs = useMemo( () => resolveTabs(), [] ); + // Ordered list of tab keys. The first entry is the default tab. const tabKeys = Object.keys( tabs ); const [ current, setCurrent ] = useState( tabKeys[ 0 ] ); diff --git a/assets/src/modules/admin/tabs/index.js b/assets/src/modules/admin/tabs/index.js index 6026bff..f54ecfd 100644 --- a/assets/src/modules/admin/tabs/index.js +++ b/assets/src/modules/admin/tabs/index.js @@ -9,26 +9,107 @@ * used for grid-heavy tabs like Addons. * * Order in this object is the tab order in the nav. + * + * Addons inject their own tabs through the `loggedin.admin.tabs` + * filter — same pattern as `loggedin.settings.panels`. Each filter + * entry must be `{ key, label, component, wide?, before?, after? }`: + * + * - `key` Stable string id. Replaces a built-in tab with the + * same key, so addons can override Settings/Addons/ + * Support if they really mean to. + * - `before` Optional — insert the new tab immediately before + * the tab with this key. Falls back to append. + * - `after` Optional — insert immediately after. Ignored when + * `before` is set. + * + * The filter is applied at import time so the result is stable across + * renders; addons that need dynamic tabs should still register here + * and feature-flag inside their component. */ +import { applyFilters } from '@wordpress/hooks'; import { __ } from '@wordpress/i18n'; import Settings from './settings'; import Addons from './addons'; import Support from './support'; -const tabs = { - settings: { +const builtIn = [ + { + key: 'settings', label: __( 'Settings', 'loggedin' ), component: Settings, }, - addons: { + { + key: 'addons', label: __( 'Addons', 'loggedin' ), component: Addons, wide: true, }, - support: { + { + key: 'support', label: __( 'Support', 'loggedin' ), component: Support, }, +]; + +const isValid = ( tab ) => + tab && typeof tab.key === 'string' && tab.key && tab.component; + +const applyOrdering = ( base, extras ) => { + const list = [ ...base ]; + + extras.forEach( ( tab ) => { + // Replace-by-key: addon entries supersede built-ins that share + // a key, letting the addon override Settings/Addons/Support + // outright when that's the intent. + const existing = list.findIndex( ( t ) => t.key === tab.key ); + if ( existing !== -1 ) { + list.splice( existing, 1 ); + } + + if ( tab.before ) { + const idx = list.findIndex( ( t ) => t.key === tab.before ); + if ( idx !== -1 ) { + list.splice( idx, 0, tab ); + return; + } + } + + if ( tab.after ) { + const idx = list.findIndex( ( t ) => t.key === tab.after ); + if ( idx !== -1 ) { + list.splice( idx + 1, 0, tab ); + return; + } + } + + list.push( tab ); + } ); + + return list; +}; + +/** + * Resolve the ordered tab registry. + * + * Called from `AdminApp` on render — applying the filter at module + * import time would race against addon bundles that haven't yet had + * a chance to `addFilter`, so we defer the lookup until React asks + * for it. Same pattern as `loggedin.settings.panels`. + * + * @return {Object} Tabs keyed by `key`, preserving insertion order. + */ +export const resolveTabs = () => { + const ordered = applyOrdering( + builtIn, + applyFilters( 'loggedin.admin.tabs', [] ).filter( isValid ) + ); + + // Preserve the legacy `{ key: tab }` shape that `AdminApp` reads — + // callers iterate `Object.keys()` for the tab order, so keep the + // insertion order from `ordered`. + return Object.fromEntries( + ordered.map( ( { key, ...rest } ) => [ key, rest ] ) + ); }; -export default tabs; +export default resolveTabs; diff --git a/assets/src/styles/admin.scss b/assets/src/styles/admin.scss index 24a29d4..29e4b52 100644 --- a/assets/src/styles/admin.scss +++ b/assets/src/styles/admin.scss @@ -10,3 +10,4 @@ @use 'ui/footer'; @use 'ui/addons'; @use 'ui/notices'; +@use 'ui/cross-sell'; diff --git a/assets/src/styles/ui/_cross-sell.scss b/assets/src/styles/ui/_cross-sell.scss new file mode 100644 index 0000000..f3c682b --- /dev/null +++ b/assets/src/styles/ui/_cross-sell.scss @@ -0,0 +1,23 @@ +/* + * Cross-sell notices — shared `.loggedin-cross-sell` wrapper used by + * settings panels that promote an addon. Mirrors the 404-to-301 + * plugin's `.d404-cross-sell` styling so the spacing reads the same + * across our product family. + * + * The wrapper is a `PanelRow`; the `Notice` inside it carries the + * status/dismiss chrome. Flatten the title's top margin so it sits + * flush with the top of the notice, and drop the trailing margin on + * the last content element so the notice isn't padded taller than + * its copy needs. + */ +.loggedin-cross-sell { + .components-notice__content { + .loggedin-cross-sell__title { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + } +} diff --git a/composer.json b/composer.json index 16f9bec..0e29132 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "loggedin/loggedin", "description": "Limit an account to a specific number of simultaneous logins across all devices.", - "version": "3.0.0", + "version": "3.0.1", "homepage": "https://wordpress.org/plugins/loggedin/", "license": "GPL-2.0+", "type": "wordpress-plugin", diff --git a/includes/class-plugin.php b/includes/class-plugin.php index fd61102..f4dedea 100644 --- a/includes/class-plugin.php +++ b/includes/class-plugin.php @@ -39,7 +39,7 @@ final class Plugin { * * @since 3.0.0 */ - public const VERSION = '3.0.0'; + public const VERSION = '3.0.1'; /** * Plugin slug used in menu URLs, asset handles and CSS classes. diff --git a/loggedin.php b/loggedin.php index b1ecac5..0719d37 100644 --- a/loggedin.php +++ b/loggedin.php @@ -20,7 +20,7 @@ * Plugin Name: Loggedin - Limit Concurrent Sessions * Plugin URI: https://duckdev.com/products/loggedin-limit-active-logins/ * Description: Limit an account to a specific number of simultaneous logins across all devices. - * Version: 3.0.0 + * Version: 3.0.1 * Author: Joel James * Author URI: https://duckdev.com/ * Donate link: https://paypal.me/JoelCJ @@ -34,7 +34,6 @@ namespace DuckDev\Loggedin; -// Block direct file access — WPINC is only defined when loaded by core. defined( 'WPINC' ) || die; /** diff --git a/package-lock.json b/package-lock.json index 25aecc5..7733931 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loggedin", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loggedin", - "version": "3.0.0", + "version": "3.0.1", "license": "GPL-2.0-or-later", "devDependencies": { "@wordpress/scripts": "^30.21.0" diff --git a/package.json b/package.json index ba6d8a0..0585f57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loggedin", - "version": "3.0.0", + "version": "3.0.1", "description": "Limit an account to a specific number of simultaneous logins across all devices.", "main": "loggedin.php", "private": true, diff --git a/readme.txt b/readme.txt index d07a659..65052ba 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: concurrent login, login limit, prevent account sharing, user sessions, for Donate link: https://paypal.me/JoelCJ Requires at least: 6.0 Tested up to: 7.0 -Stable tag: 3.0.0 +Stable tag: 3.0.1 Requires PHP: 7.4 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -53,6 +53,7 @@ There's a one-click **Force Logout** panel in the admin to clear every session f Extend Loggedin with these official [add-ons](https://duckdev.com/addons/loggedin/): +* **[Active Sessions](https://duckdev.com/addon/loggedin-active-sessions/)** — See exactly who's signed in right now, drill into each device per user, and sign out a single session — or every session — in one click. * **[Limit Per User](https://duckdev.com/addon/limit-per-user/)** — Override the global session cap for an individual user account directly from their WordPress profile. Perfect for tiered access or trusted-staff exemptions. * **[Limit Per Role](https://duckdev.com/addon/limit-per-role/)** — Set a different concurrent-session cap per WordPress role. Give administrators more headroom while keeping subscribers tight, or vice versa. * **[Real-time Logout](https://duckdev.com/addon/real-time-logout/)** — Detect logouts in near-real-time. When Loggedin terminates a session, the user's other open tabs reload to wp-login automatically — no waiting for the next page click. @@ -171,6 +172,12 @@ See the [developer docs](https://docs.duckdev.com/loggedin/developer-docs) for e == Changelog == += 3.0.1 = +* New: `loggedin.admin.tabs` JS filter — addons can register their own React component as a tab in the Loggedin admin nav, with optional `before` / `after` positioning hints. Powers the new Active Sessions addon. +* New: Cross-sell banner on the Force Logout panel routed through `loggedin.settings.force_logout.cross_sell` so addons can hide or replace it once installed. +* Improve: Addon card layout aligned with the 404 to 301 plugin — primary CTA pinned to the left of the footer, "More details" link on the right, title-cased license button labels. +* Fix: The v2 → v3 settings migration never ran on existing installs, leaving legacy option keys in place after the upgrade. + = 3.0.0 = * New: Modern React-powered admin under Users → Loggedin with two tabs — Settings (concurrent-login limit + login logic + Force Logout panel) and Add-ons (catalogue + license management). * New: REST API at `/loggedin/v1/` for settings, session management and add-on licensing. @@ -194,11 +201,8 @@ For the full release history, see the [changelog](https://docs.duckdev.com/logge == Upgrade Notice == += 3.0.1 = +A maintenance release that fixes the v2→v3 settings migration and adds the JS extension points the new Active Sessions addon hooks into. + = 3.0.0 = A major release with a brand-new React admin, REST API, add-ons catalogue with in-dashboard license management and a documented hook surface. Back up your database before updating. - -= 2.0.4 = -A maintenance release that fixes the review-notice dismiss action and respects the dismiss state across admin page loads. - -= 2.0.3 = -A maintenance release that removes leftover debug code shipped accidentally in 2.0.2.