From 9585acd0b4e50b48ecf52ec845da68ee1ad6d7dd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 14:29:34 +0800 Subject: [PATCH 01/12] feat(server): console endpoints for the operator redesign - Explore facets: pricing, hosting=both, sort and hide_installed (paged over the whole filtered catalog because installed state is only known here) - GET registry-extensions/{id} reads Registry Service listings; GET registry-extensions/{id}/releases maps the service releases endpoint - GET registry-extensions/context: service, registration, instance kind, publisher portal URL and checkout availability for every user - POST purchases/checkout and GET purchases/{handoff} for the Phase 4 service checkout; off until the service advertises features.checkout (REGISTRY_SERVICE_CHECKOUT=auto|true|false) - install blocks carry a code (not_registered, hosting, licence_required, no_release) and listings say whether purchase is available - POST installer/update; install and update accept a version - InstallExtensionRequest no longer requires a local registry_extensions row, which refused every Registry Service install from the console --- server/config/registry-bridge.php | 5 + .../v1/ExtensionInstallerController.php | 11 +- .../v1/RegistryExtensionController.php | 94 ++++++++ .../Internal/v1/ServicePurchaseController.php | 67 ++++++ .../Http/Requests/InstallExtensionRequest.php | 4 +- server/src/Support/RegistryClient.php | 91 +++++++- server/src/Support/RegistryConfig.php | 34 +++ server/src/Support/ServiceCatalog.php | 158 ++++++++++++-- server/src/Support/ServiceCheckout.php | 112 ++++++++++ server/src/routes.php | 9 + server/tests/Unit/ServiceConsoleTest.php | 200 ++++++++++++++++++ 11 files changed, 764 insertions(+), 21 deletions(-) create mode 100644 server/src/Http/Controllers/Internal/v1/ServicePurchaseController.php create mode 100644 server/src/Support/ServiceCheckout.php create mode 100644 server/tests/Unit/ServiceConsoleTest.php diff --git a/server/config/registry-bridge.php b/server/config/registry-bridge.php index 147b666..4cd9675 100644 --- a/server/config/registry-bridge.php +++ b/server/config/registry-bridge.php @@ -33,6 +33,11 @@ // `self_hosted` or `cloud`; defaults to `cloud` when `self_hosted` (this instance is the v1 registry) is true. 'instance_kind' => env('REGISTRY_INSTANCE_KIND'), 'instance' => [], + // Publisher portal the console links to (the service's website). Defaults to the service URL. + 'publisher_url' => env('REGISTRY_PUBLISHER_URL'), + // Purchases through the service checkout (Phase 4): `auto` follows `features.checkout` from + // GET /v1/instances/me, `true` or `false` force it on or off. + 'checkout' => env('REGISTRY_SERVICE_CHECKOUT', 'auto'), ], 'stripe' => [ 'key' => env('STRIPE_KEY', env('STRIPE_API_KEY')), diff --git a/server/src/Http/Controllers/Internal/v1/ExtensionInstallerController.php b/server/src/Http/Controllers/Internal/v1/ExtensionInstallerController.php index d7ea4f6..ae7747d 100644 --- a/server/src/Http/Controllers/Internal/v1/ExtensionInstallerController.php +++ b/server/src/Http/Controllers/Internal/v1/ExtensionInstallerController.php @@ -46,6 +46,15 @@ public function uninstall(InstallExtensionRequest $request, InstallerService $in return $this->enqueue($installer, 'uninstall', $request); } + /** + * Queue an update of an installed extension, to `version` or the newest compatible release. Same + * responses as install(); progress arrives on `install.{company}.{extension}`. + */ + public function update(InstallExtensionRequest $request, InstallerService $installer) + { + return $this->enqueue($installer, 'update', $request); + } + /** * An installer job of the current company: status, stage, progress, queue * position and, once finished, the result (failed stage, message and the @@ -83,7 +92,7 @@ private function enqueue(InstallerService $installer, string $action, InstallExt ? RegistryServiceInstall::where(['company_uuid' => session('company'), 'extension_id' => $job['extension']['id'] ?? null])->first() : RegistryExtensionInstall::where(['company_uuid' => session('company'), 'extension_uuid' => $job['extension']['uuid'] ?? null])->first(); - return response()->json(['status' => 'ok', 'job' => $job, $action === 'install' ? 'install' : 'uninstalled' => $action === 'install' ? $install : $install === null]); + return response()->json(['status' => 'ok', 'job' => $job, $action === 'uninstall' ? 'uninstalled' : 'install' => $action === 'uninstall' ? $install === null : $install]); } /** diff --git a/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php b/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php index c5b01ae..10afc15 100644 --- a/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php +++ b/server/src/Http/Controllers/Internal/v1/RegistryExtensionController.php @@ -89,6 +89,100 @@ public function queryRecord(Request $request) return parent::queryRecord($request); } + /** + * Find one extension. Registry Service listings (`ext_…` ids or slugs) are read from the service and + * decorated with this instance's install and licence state; anything else is a local record. + * + * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Resources\Json\JsonResource|array + */ + public function findRecord(Request $request, $id) + { + $config = RegistryConfig::current(); + if ($config->usesService() && !RegistryExtension::where('public_id', $id)->orWhere('uuid', $id)->exists()) { + try { + $extension = ServiceCatalog::find((string) $id, RegistryClient::fromConfig($config), $config, CentralCatalog::installedPackageNames(session('company'))); + } catch (RegistryException $e) { + return response()->error($e->getMessage(), 503); + } + + if ($extension === null) { + return response()->error('Extension not found.', 404); + } + + return response()->json(['registryExtension' => $extension]); + } + + return parent::findRecord($request, $id); + } + + /** + * Versions of an extension for the console's Versions tab. + * + * Registry Service listings return their published and yanked releases; other records have no release + * history, so only the current bundle version is returned with `supported: false`. + * + * @return \Illuminate\Http\JsonResponse `{supported, latest, channels, releases}` + */ + public function releases(Request $request, string $id) + { + $config = RegistryConfig::current(); + $local = RegistryExtension::where('public_id', $id)->orWhere('uuid', $id)->with('currentBundle')->first(); + + if ($local === null && $config->usesService()) { + try { + $releases = ServiceCatalog::releases($id, RegistryClient::fromConfig($config), $request->input('installed_version')); + } catch (RegistryException $e) { + return response()->error($e->getMessage(), 503); + } + + if ($releases === null) { + return response()->error('Extension not found.', 404); + } + + return response()->json(['supported' => true, ...$releases]); + } + + $version = $local?->currentBundle?->version; + + return response()->json([ + 'supported' => false, + 'latest' => $version, + 'channels' => [], + 'releases' => $version ? [['version' => $version, 'channel' => 'stable', 'status' => 'published', 'is_latest' => true, 'is_installed' => false, 'is_yanked' => false]] : [], + ]); + } + + /** + * What the console needs to know about the registry for every user: whether it talks to the Registry + * Service, whether this instance is registered, its kind, the publisher portal to link to and whether + * paid extensions can be bought here. No secrets. + * + * @return \Illuminate\Http\JsonResponse + */ + public function context(Request $request) + { + $config = RegistryConfig::current(); + $features = []; + if ($config->usesService() && $config->isRegistered()) { + try { + $features = RegistryClient::fromConfig($config)->instance()['features']; + } catch (RegistryException $e) { + // Unreachable or refused: checkout stays off in `auto` mode + } + } + + return response()->json([ + 'uses_service' => $config->usesService(), + 'api_version' => $config->apiVersion(), + 'registered' => $config->isRegistered(), + 'instance_kind' => $config->instanceKind(), + 'service_url' => $config->serviceUrl(), + 'publisher_url' => $config->publisherUrl(), + 'checkout_available' => $config->checkoutEnabled($features), + 'facilitator_fee' => (float) config('registry-bridge.facilitator_fee', 10), + ]); + } + /** * Creates a record with request payload. * diff --git a/server/src/Http/Controllers/Internal/v1/ServicePurchaseController.php b/server/src/Http/Controllers/Internal/v1/ServicePurchaseController.php new file mode 100644 index 0000000..1a705a9 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/ServicePurchaseController.php @@ -0,0 +1,67 @@ +input('extension', '')); + $returnUrl = trim((string) $request->input('return_url', '')); + if ($extension === '' || $returnUrl === '') { + return response()->error('An extension and a return URL are required.', 422); + } + + $config = RegistryConfig::current(); + + try { + $checkout = ServiceCheckout::start(RegistryClient::fromConfig($config), $config, $extension, $returnUrl, $request->user()?->email); + } catch (RegistryServiceException $e) { + return response()->error($e->getMessage(), in_array($e->status(), [409, 501], true) ? $e->status() : ($e->status() >= 400 && $e->status() < 500 ? 422 : 502)); + } catch (RegistryException $e) { + return response()->error($e->getMessage(), 503); + } + + return response()->json($checkout); + } + + /** + * The status of a checkout hand-off: `pending` until the service's webhook records the purchase, then + * `completed` or `failed`. + * + * @return \Illuminate\Http\JsonResponse `{status, service_status, extension, receipt_url, message}` + */ + public function handoff(string $handoff) + { + $config = RegistryConfig::current(); + + try { + $body = RegistryClient::fromConfig($config)->purchase($handoff); + } catch (RegistryServiceException $e) { + return response()->error($e->getMessage(), $e->status() === 404 ? 404 : ($e->status() >= 400 && $e->status() < 500 ? 422 : 502)); + } catch (RegistryException $e) { + return response()->error($e->getMessage(), 503); + } + + return response()->json(ServiceCheckout::describe($body)); + } +} diff --git a/server/src/Http/Requests/InstallExtensionRequest.php b/server/src/Http/Requests/InstallExtensionRequest.php index 98edb1c..1fc1c07 100644 --- a/server/src/Http/Requests/InstallExtensionRequest.php +++ b/server/src/Http/Requests/InstallExtensionRequest.php @@ -24,7 +24,9 @@ public function authorize() public function rules() { return [ - 'extension' => ['required', 'exists:registry_extensions,public_id'], + // A local public id or a Registry Service id, slug or package name; the installer resolves it + 'extension' => ['required', 'string', 'max:255'], + 'version' => ['nullable', 'string', 'max:64'], ]; } } diff --git a/server/src/Support/RegistryClient.php b/server/src/Support/RegistryClient.php index 9092e33..5def661 100644 --- a/server/src/Support/RegistryClient.php +++ b/server/src/Support/RegistryClient.php @@ -66,6 +66,11 @@ class RegistryClient public const INSTANCE_INSTALLS_PATH = 'v1/instances/me/installs'; + /** Phase 4: hosted Stripe Checkout for the instance's organization, and the hand-off polled after it. */ + public const INSTANCE_CHECKOUT_PATH = 'v1/instances/me/purchases/checkout'; + + public const INSTANCE_PURCHASES_PATH = 'v1/instances/me/purchases'; + protected const CACHE_PREFIX = 'registry-bridge:registry-client'; protected string $host; @@ -254,6 +259,37 @@ public function catalogExtension(string $slug): ?array return is_array($payload) ? static::unwrap($payload) : null; } + /** + * v2: published and yanked releases of a listing (`GET /v1/catalog/extensions/{slug}/releases`), newest + * first, or null when the listing does not exist. + * + * @return array{extension: string|null, latest: string|null, channels: array, releases: array>}|null + * + * @throws RegistryUnavailableException|RegistryServiceException + */ + public function catalogReleases(string $slug): ?array + { + try { + $payload = $this->cachedGet(static::CATALOG_EXTENSIONS_PATH . '/' . rawurlencode($slug) . '/releases'); + } catch (RegistryServiceException $e) { + if ($e->status() === 404) { + return null; + } + + throw $e; + } + + $payload = is_array($payload) ? static::unwrap($payload) : []; + $channels = is_array($payload['channels'] ?? null) ? array_filter($payload['channels'], 'is_string') : []; + + return [ + 'extension' => is_string($payload['extension'] ?? null) ? $payload['extension'] : null, + 'latest' => is_string($payload['latest'] ?? null) ? $payload['latest'] : null, + 'channels' => $channels, + 'releases' => is_array($payload['releases'] ?? null) ? array_values(array_filter($payload['releases'], 'is_array')) : [], + ]; + } + /** * v2: categories with published extension counts (`GET /v1/catalog/categories`). * @@ -269,7 +305,10 @@ public function catalogCategories(): array /** * v2: this instance, its organization and the organization's licences (`GET /v1/instances/me`). * - * @return array{instance: array, licences: array>} + * `features` lists what the service supports for instances (`checkout` once Phase 4 ships); empty when the + * service does not say. + * + * @return array{instance: array, licences: array>, features: array} * * @throws RegistryUnavailableException|RegistryServiceException */ @@ -282,9 +321,53 @@ public function instance(): array return [ 'instance' => is_array($payload['instance'] ?? null) ? $payload['instance'] : [], 'licences' => is_array($payload['licences'] ?? null) ? array_values(array_filter($payload['licences'], 'is_array')) : [], + 'features' => is_array($payload['features'] ?? null) ? $payload['features'] : [], ]; } + /** + * v2 (Phase 4): start a hosted Stripe Checkout for the organization (`POST /v1/instances/me/purchases/checkout`). + * + * A service without the endpoint answers 404 or 405, which surfaces as a `RegistryServiceException`. + * + * @param array $payload `extension` (id or slug), `return_url`, optional `cancel_url`, `buyer_email` + * + * @return array{url: string, handoff: string} + * + * @throws RegistryUnavailableException|RegistryServiceException + */ + public function createCheckout(array $payload): array + { + $this->requireToken(); + $body = static::unwrap($this->send('post', static::INSTANCE_CHECKOUT_PATH, array_filter($payload, fn ($value) => $value !== null && $value !== ''))); + + $url = $body['url'] ?? $body['checkout_url'] ?? null; + $handoff = $body['handoff'] ?? $body['handoff_id'] ?? null; + if (!is_string($url) || $url === '' || !is_string($handoff) || $handoff === '') { + throw new RegistryServiceException('The registry service did not return a checkout URL and hand-off id.', 502); + } + + return ['url' => $url, 'handoff' => $handoff]; + } + + /** + * v2 (Phase 4): the status of a checkout hand-off (`GET /v1/instances/me/purchases/{handoff}`), never cached. + * + * @return array at least `status` + * + * @throws RegistryUnavailableException|RegistryServiceException + */ + public function purchase(string $handoff): array + { + $this->requireToken(); + $body = static::unwrap($this->send('get', static::INSTANCE_PURCHASES_PATH . '/' . static::encodePathSegment($handoff))); + if (($body['status'] ?? null) !== null) { + $this->forgetInstanceState(); + } + + return $body; + } + /** * v2: installed releases against the newest compatible release (`GET /v1/instances/me/updates`). * @@ -483,7 +566,11 @@ protected function send(string $method, string $path, array $payload = [], bool { try { $request = $this->request($withToken)->asJson(); - $response = $method === 'delete' ? $request->delete($path, $payload) : $request->post($path, $payload); + $response = match ($method) { + 'delete' => $request->delete($path, $payload), + 'get' => $request->get($path, $payload), + default => $request->post($path, $payload), + }; } catch (\Throwable $e) { throw new RegistryUnavailableException('Unable to reach the extensions registry at ' . $this->host . ': ' . $e->getMessage(), 0, $e); } diff --git a/server/src/Support/RegistryConfig.php b/server/src/Support/RegistryConfig.php index 286d079..90f10ea 100644 --- a/server/src/Support/RegistryConfig.php +++ b/server/src/Support/RegistryConfig.php @@ -173,6 +173,40 @@ public function composerRepositoryUrl(): ?string return $url ? $url . '/composer' : null; } + /** + * Publisher portal URL (the service's website), or null without a service. + */ + public function publisherUrl(): ?string + { + $url = $this->string('publisher_url'); + if ($url !== '') { + return static::normalizeUrl($url); + } + + return $this->usesService() ? $this->serviceUrl() : null; + } + + /** + * Whether purchases go through the service checkout, given what the service advertises. + * + * `checkout` is `auto` (follow the service's `features.checkout`), or a boolean to force it. + * + * @param array $features `features` from `GET /v1/instances/me` + */ + public function checkoutEnabled(array $features = []): bool + { + if (!$this->usesService() || !$this->isRegistered()) { + return false; + } + + $mode = $this->registry['checkout'] ?? 'auto'; + if (is_string($mode) && strtolower(trim($mode)) === 'auto') { + return Utils::castBoolean($features['checkout'] ?? false) === true; + } + + return Utils::castBoolean($mode) === true; + } + /** * Prefix a URL with https:// when it has no scheme and drop the trailing slash. */ diff --git a/server/src/Support/ServiceCatalog.php b/server/src/Support/ServiceCatalog.php index 9805881..5b61369 100644 --- a/server/src/Support/ServiceCatalog.php +++ b/server/src/Support/ServiceCatalog.php @@ -41,20 +41,36 @@ class ServiceCatalog */ public static function query(Request $request, RegistryClient $client, RegistryConfig $config, array $localPackages = []): LengthAwarePaginator { - $search = trim(static::str($request->input('query', ''))); - $category = trim(static::str($request->input('category', ''))); - $limit = static::int($request->input('limit', static::DEFAULT_LIMIT), static::DEFAULT_LIMIT); - $page = max(1, static::int($request->input('page', 1), 1)); + $search = trim(static::str($request->input('query', ''))); + $category = trim(static::str($request->input('category', ''))); + $limit = static::int($request->input('limit', static::DEFAULT_LIMIT), static::DEFAULT_LIMIT); + $page = max(1, static::int($request->input('page', 1), 1)); + $hideInstalled = Utils::castBoolean($request->input('hide_installed', false)) === true; $filters = array_filter([ 'q' => $search, 'category' => $category, - 'hosting' => $config->instanceKind(), - 'sort' => static::str($request->input('sort', '')), + 'pricing' => static::pricingFilter($request->input('pricing')), + 'hosting' => static::hostingFilter($request->input('hosting'), $config->instanceKind()), + 'sort' => static::sortFilter($request->input('sort')), ], fn ($value) => $value !== ''); $state = static::state($client, $config, $localPackages); + // Installed state is only known here, so hiding installed listings pages over the whole filtered catalog + if ($hideInstalled) { + $items = array_values(array_filter( + static::transformMany($client->allCatalogExtensions($filters), $state, $client->host()), + fn (array $item) => $item['is_installed'] !== true + )); + $limit = $limit === -1 ? max(1, count($items)) : max(1, min($limit, static::MAX_LIMIT)); + + return new LengthAwarePaginator(array_slice($items, ($page - 1) * $limit, $limit), count($items), $limit, $page, [ + 'path' => $request->url(), + 'query' => $request->query(), + ]); + } + if ($limit === -1) { $listings = $client->allCatalogExtensions($filters); $items = static::transformMany($listings, $state, $client->host()); @@ -213,7 +229,7 @@ public static function lookup(string $package, RegistryClient $client): ?array * * @param array $localPackages * - * @return array{registered: bool, kind: string, installed: array>, licences: array>, local_packages: array, local_ids: array, error: string|null} + * @return array{registered: bool, kind: string, checkout: bool, installed: array>, licences: array>, local_packages: array, local_ids: array, error: string|null} */ public static function state(RegistryClient $client, RegistryConfig $config, array $localPackages = []): array { @@ -230,7 +246,7 @@ public static function state(RegistryClient $client, RegistryConfig $config, arr } } - $state = static::buildState($me, $updates, $localPackages, $client->hasToken(), $config->instanceKind()); + $state = static::buildState($me, $updates, $localPackages, $client->hasToken(), $config->instanceKind(), $config->checkoutEnabled(static::arr($me['features'] ?? null))); $state['error'] = $error; return $state; @@ -242,10 +258,11 @@ public static function state(RegistryClient $client, RegistryConfig $config, arr * @param array $me `GET /v1/instances/me` body * @param array $updates `GET /v1/instances/me/updates` body * @param array $localPackages + * @param bool $checkout whether paid listings can be bought through the service checkout * - * @return array{registered: bool, kind: string, installed: array>, licences: array>, local_packages: array, local_ids: array, error: string|null} + * @return array{registered: bool, kind: string, checkout: bool, installed: array>, licences: array>, local_packages: array, local_ids: array, error: string|null} */ - public static function buildState(array $me = [], array $updates = [], array $localPackages = [], bool $registered = false, string $kind = RegistryConfig::KIND_SELF_HOSTED): array + public static function buildState(array $me = [], array $updates = [], array $localPackages = [], bool $registered = false, string $kind = RegistryConfig::KIND_SELF_HOSTED, bool $checkout = false): array { $installed = []; foreach ((array) ($updates['data'] ?? []) as $entry) { @@ -271,6 +288,7 @@ public static function buildState(array $me = [], array $updates = [], array $lo return [ 'registered' => $registered, 'kind' => $kind, + 'checkout' => $checkout, 'installed' => $installed, 'licences' => $licences, 'local_packages' => array_values(array_filter($localPackages, fn ($name) => is_string($name) && $name !== '')), @@ -325,7 +343,9 @@ public static function transform(array $listing, array $state = [], ?string $ser $installedVersion = $update ? static::str(Arr::get($update, 'installed.version')) : ''; $updateVersion = $update ? static::str(Arr::get($update, 'latest.version')) : ''; - $blockedReason = static::installBlockedReason($registered, $hosting, $kind, $isPaid, $isPurchased, $names, $latest); + $checkout = (bool) ($state['checkout'] ?? false); + $blocked = static::installBlock($registered, $hosting, $kind, $isPaid, $isPurchased, $names, $latest, $checkout); + $blockedReason = $blocked['reason'] ?? null; return [ 'id' => $id, @@ -396,6 +416,8 @@ public static function transform(array $listing, array $state = [], ?string $ser 'latest_release' => $release ?: null, 'licence' => $licence, 'install_blocked_reason' => $blockedReason, + 'install_blocked_code' => $blocked['code'] ?? null, + 'purchase_available' => ($blocked['code'] ?? null) === 'licence_required' && $checkout, 'versions' => array_values((array) Arr::get($listing, 'versions', [])), ], 'published_at' => Arr::get($listing, 'published_at'), @@ -444,6 +466,93 @@ public static function transformCategory(mixed $category): ?array ]; } + /** + * The service `pricing` filter: `free`, `paid`, or empty for both. + */ + public static function pricingFilter(mixed $pricing): string + { + $pricing = strtolower(trim(static::str($pricing))); + + return in_array($pricing, ['free', 'paid'], true) ? $pricing : ''; + } + + /** + * The service `hosting` filter. Listings that exclude this instance's kind stay hidden, so the only + * narrowing an operator can ask for is `both` (listings that run on cloud and self-hosted alike). + */ + public static function hostingFilter(mixed $hosting, string $kind): string + { + return strtolower(trim(static::str($hosting))) === 'both' ? 'both' : $kind; + } + + /** + * The service `sort`: `popular` (default), `newest`, `updated`, `name` or `price`. + */ + public static function sortFilter(mixed $sort): string + { + $sort = strtolower(trim(static::str($sort))); + + return in_array($sort, ['popular', 'newest', 'updated', 'name', 'price'], true) ? $sort : ''; + } + + /** + * Releases of a listing for the console's Versions tab, newest first, or null when the service does not + * have the listing. + * + * @return array{latest: string|null, channels: array, releases: array>}|null + * + * @throws RegistryException + */ + public static function releases(string $id, RegistryClient $client, ?string $installedVersion = null): ?array + { + $payload = $client->catalogReleases($id); + if ($payload === null) { + return null; + } + + return static::transformReleases($payload, $installedVersion); + } + + /** + * Map a releases payload: each release with `is_latest`, `is_installed` and `is_yanked` flags. + * + * @param array $payload `{latest, channels, releases}` from the service + * + * @return array{latest: string|null, channels: array, releases: array>} + */ + public static function transformReleases(array $payload, ?string $installedVersion = null): array + { + $latest = static::str($payload['latest'] ?? '') ?: null; + $releases = []; + foreach ((array) ($payload['releases'] ?? []) as $release) { + if (!is_array($release) || static::str($release['version'] ?? '') === '') { + continue; + } + + $version = static::str($release['version']); + $releases[] = [ + 'id' => $release['id'] ?? null, + 'version' => $version, + 'channel' => static::str($release['channel'] ?? '') ?: 'stable', + 'status' => static::str($release['status'] ?? '') ?: 'published', + 'compat' => is_array($release['compat'] ?? null) ? $release['compat'] : [], + 'size_bytes' => static::int($release['size_bytes'] ?? 0), + 'notes' => $release['notes'] ?? null, + 'published_at' => $release['published_at'] ?? null, + 'yanked_at' => $release['yanked_at'] ?? null, + 'is_latest' => $version === $latest, + 'is_installed' => $installedVersion !== null && $version === $installedVersion, + 'is_yanked' => ($release['status'] ?? null) === 'yanked' || ($release['yanked_at'] ?? null) !== null, + ]; + } + + return [ + 'latest' => $latest, + 'channels' => array_filter((array) ($payload['channels'] ?? []), 'is_string'), + 'releases' => $releases, + ]; + } + /** * `{npm, composer}` package names a listing declares. * @@ -484,24 +593,39 @@ public static function findListingByPackage(array $listings, string $package): ? * * @param array $packageNames */ - public static function installBlockedReason(bool $registered, string $hosting, string $kind, bool $isPaid, bool $isPurchased, array $packageNames, string $version): ?string + public static function installBlockedReason(bool $registered, string $hosting, string $kind, bool $isPaid, bool $isPurchased, array $packageNames, string $version, bool $checkout = false): ?string + { + return static::installBlock($registered, $hosting, $kind, $isPaid, $isPurchased, $packageNames, $version, $checkout)['reason'] ?? null; + } + + /** + * Why this instance cannot install a listing, as a code the console branches on (`not_registered`, + * `hosting`, `licence_required`, `no_release`) and the sentence it shows, or null when it can. + * + * @param array $packageNames + * + * @return array{code: string, reason: string}|null + */ + public static function installBlock(bool $registered, string $hosting, string $kind, bool $isPaid, bool $isPurchased, array $packageNames, string $version, bool $checkout = false): ?array { if (!$registered) { - return 'Register this instance with the registry service to install extensions.'; + return ['code' => 'not_registered', 'reason' => 'Register this instance with the registry service to install extensions.']; } if ($hosting !== 'both' && $hosting !== $kind) { - return $hosting === RegistryConfig::KIND_CLOUD + return ['code' => 'hosting', 'reason' => $hosting === RegistryConfig::KIND_CLOUD ? 'This extension is only available on Fleetbase Cloud.' - : 'This extension is only available on self-hosted instances.'; + : 'This extension is only available on self-hosted instances.']; } if ($isPaid && !$isPurchased) { - return 'Your organization does not hold a licence for this extension. Purchasing through the registry service is not available yet.'; + return ['code' => 'licence_required', 'reason' => $checkout + ? 'Purchase a licence for your organization to install this extension.' + : 'Your organization does not hold a licence for this extension. Purchasing through the registry service is not available yet.']; } if ($packageNames === [] || $version === '') { - return 'This extension has no published release to install.'; + return ['code' => 'no_release', 'reason' => 'This extension has no published release to install.']; } return null; diff --git a/server/src/Support/ServiceCheckout.php b/server/src/Support/ServiceCheckout.php new file mode 100644 index 0000000..8b98008 --- /dev/null +++ b/server/src/Support/ServiceCheckout.php @@ -0,0 +1,112 @@ + self::PENDING, + 'open' => self::PENDING, + 'processing' => self::PENDING, + 'paid' => self::COMPLETED, + 'complete' => self::COMPLETED, + 'completed' => self::COMPLETED, + 'succeeded' => self::COMPLETED, + 'expired' => self::FAILED, + 'failed' => self::FAILED, + 'canceled' => self::FAILED, + 'cancelled' => self::FAILED, + ]; + + /** + * Start a checkout for one listing and return where to send the buyer. + * + * @return array{url: string, handoff: string} + * + * @throws RegistryException when checkout is off, the service lacks the endpoint, or it refuses + */ + public static function start(RegistryClient $client, RegistryConfig $config, string $extension, string $returnUrl, ?string $buyerEmail = null): array + { + if (!$config->usesService()) { + throw new RegistryServiceException('Purchases through the registry service need a Registry Service URL.', 409); + } + + if (!$config->isRegistered()) { + throw new RegistryServiceException('Register this instance with the registry service before purchasing extensions.', 409); + } + + $features = []; + try { + $features = $client->instance()['features']; + } catch (RegistryException $e) { + // An unreachable instance API leaves `auto` mode off; a forced mode still tries + } + + if (!$config->checkoutEnabled($features)) { + throw new RegistryServiceException(static::unavailableReason(), 409); + } + + try { + return $client->createCheckout([ + 'extension' => $extension, + 'return_url' => $returnUrl, + 'cancel_url' => $returnUrl, + 'buyer_email' => $buyerEmail, + ]); + } catch (RegistryServiceException $e) { + if (in_array($e->status(), [404, 405], true)) { + throw new RegistryServiceException(static::unavailableReason(), 501, [], [], $e); + } + + throw $e; + } + } + + /** + * The hand-off as the console reads it: `status` (pending, completed or failed), the service's own status, + * and the extension and receipt when the service sends them. + * + * @param array $body `GET /v1/instances/me/purchases/{handoff}` body + * + * @return array{status: string, service_status: string|null, extension: mixed, receipt_url: string|null, message: string|null} + */ + public static function describe(array $body): array + { + $purchase = is_array($body['purchase'] ?? null) ? $body['purchase'] : []; + $serviceStatus = $body['status'] ?? $purchase['status'] ?? null; + $serviceStatus = is_string($serviceStatus) ? strtolower($serviceStatus) : null; + + $receipt = $body['receipt_url'] ?? $purchase['receipt_url'] ?? null; + $message = $body['message'] ?? $body['error'] ?? null; + + return [ + 'status' => static::STATUS_MAP[$serviceStatus] ?? static::PENDING, + 'service_status' => $serviceStatus, + 'extension' => $body['extension'] ?? $purchase['extension'] ?? null, + 'receipt_url' => is_string($receipt) ? $receipt : null, + 'message' => is_string($message) ? $message : null, + ]; + } + + public static function unavailableReason(): string + { + return 'Purchasing through the registry service is not available yet.'; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 28ea432..c0af7da 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -58,6 +58,7 @@ function ($router) { $router->group(['prefix' => 'installer'], function ($router) { $router->post('install', 'ExtensionInstallerController@install'); $router->post('uninstall', 'ExtensionInstallerController@uninstall'); + $router->post('update', 'ExtensionInstallerController@update'); $router->get('jobs/{id}', 'ExtensionInstallerController@job'); }); @@ -71,7 +72,15 @@ function ($router) { $router->get('author-received', 'RegistryPaymentsController@getAuthorReceivedPayments'); }); + // Registry Service checkout (Phase 4) + $router->group(['prefix' => 'purchases'], function ($router) { + $router->post('checkout', 'ServicePurchaseController@checkout'); + $router->get('{handoff}', 'ServicePurchaseController@handoff'); + }); + $router->fleetbaseRoutes('registry-extensions', function ($router, $controller) { + $router->get('context', $controller('context')); + $router->get('{id}/releases', $controller('releases')); $router->post('{id}/submit', $controller('submit')); $router->post('approve', $controller('approve')); $router->post('reject', $controller('reject')); diff --git a/server/tests/Unit/ServiceConsoleTest.php b/server/tests/Unit/ServiceConsoleTest.php new file mode 100644 index 0000000..d407b38 --- /dev/null +++ b/server/tests/Unit/ServiceConsoleTest.php @@ -0,0 +1,200 @@ + 'registry-demo-hello', + 'latest' => '1.1.0', + 'channels' => ['stable' => '1.1.0', 'beta' => '1.2.0-beta.1'], + 'releases' => [ + ['id' => 'rel_3', 'version' => '1.2.0-beta.1', 'channel' => 'beta', 'status' => 'published', 'compat' => ['fleetbase' => '>=0.7'], 'size_bytes' => 2048, 'notes' => 'Beta', 'published_at' => '2026-10-20T00:00:00Z', 'yanked_at' => null], + ['id' => 'rel_2', 'version' => '1.1.0', 'channel' => 'stable', 'status' => 'published', 'compat' => [], 'size_bytes' => 1024, 'notes' => null, 'published_at' => '2026-10-10T00:00:00Z', 'yanked_at' => null], + ['id' => 'rel_1', 'version' => '1.0.0', 'channel' => 'stable', 'status' => 'yanked', 'compat' => [], 'size_bytes' => 900, 'notes' => null, 'published_at' => '2026-10-01T00:00:00Z', 'yanked_at' => '2026-10-02T00:00:00Z'], + 'not a release', + ], + ]; +} + +beforeEach(function () { + $this->service = new RegistryServiceFake(); +}); + +test('Explore passes pricing, sort and the both-hosting facet to the service and ignores unknown values', function () { + $this->service + ->on('GET', 'v1/catalog/extensions', 'catalog-extensions') + ->on('GET', 'v1/instances/me', 'instances-me-installed') + ->on('GET', 'v1/instances/me/updates', 'instances-me-updates'); + $client = RegistryClient::fromConfig(RegistryServiceFake::config()); + + ServiceCatalog::query(Request::create('/registry-extensions', 'GET', ['explore' => 1, 'pricing' => 'paid', 'hosting' => 'both', 'sort' => 'newest']), $client, RegistryServiceFake::config()); + ServiceCatalog::query(Request::create('/registry-extensions', 'GET', ['explore' => 1, 'pricing' => 'gratis', 'hosting' => 'cloud', 'sort' => 'random']), $client, RegistryServiceFake::config()); + + [$first, $second] = $this->service->requestsTo('GET', 'v1/catalog/extensions'); + expect($first->url())->toContain('pricing=paid') + ->and($first->url())->toContain('hosting=both') + ->and($first->url())->toContain('sort=newest') + ->and($second->url())->not->toContain('pricing=') + ->and($second->url())->toContain('hosting=self_hosted') + ->and($second->url())->not->toContain('sort='); +}); + +test('hiding installed extensions filters the whole catalog and pages the rest', function () { + $this->service + ->on('GET', 'v1/catalog/extensions', 'catalog-extensions') + ->on('GET', 'v1/instances/me', 'instances-me-installed') + ->on('GET', 'v1/instances/me/updates', 'instances-me-updates'); + $client = RegistryClient::fromConfig(RegistryServiceFake::config()); + + $page = ServiceCatalog::query(Request::create('/registry-extensions', 'GET', ['explore' => 1, 'hide_installed' => 'true', 'limit' => 10]), $client, RegistryServiceFake::config()); + + expect($page->total())->toBe(1) + ->and($page->items()[0]['slug'])->toBe('registry-demo-pro') + ->and($page->items()[0]['is_installed'])->toBeFalse(); +}); + +test('install blocks carry a code the console branches on', function () { + expect(ServiceCatalog::installBlock(false, 'both', 'self_hosted', false, false, ['a'], '1.0.0')['code'])->toBe('not_registered') + ->and(ServiceCatalog::installBlock(true, 'cloud', 'self_hosted', false, false, ['a'], '1.0.0')['code'])->toBe('hosting') + ->and(ServiceCatalog::installBlock(true, 'both', 'self_hosted', true, false, ['a'], '1.0.0')['code'])->toBe('licence_required') + ->and(ServiceCatalog::installBlock(true, 'both', 'self_hosted', false, false, [], ''))->toMatchArray(['code' => 'no_release']) + ->and(ServiceCatalog::installBlock(true, 'both', 'self_hosted', true, true, ['a'], '1.0.0'))->toBeNull(); +}); + +test('a paid listing offers purchase only when the service checkout is available', function () { + $listing = consoleListing('registry-demo-pro'); + $without = ServiceCatalog::buildState(RegistryServiceFake::body('instances-me'), [], [], true); + $with = ServiceCatalog::buildState(RegistryServiceFake::body('instances-me'), [], [], true, 'self_hosted', true); + $without['licences'] = $with['licences'] = []; + + $blocked = ServiceCatalog::transform($listing, $without); + $available = ServiceCatalog::transform($listing, $with); + + expect($blocked['meta']['purchase_available'])->toBeFalse() + ->and($blocked['meta']['install_blocked_code'])->toBe('licence_required') + ->and($blocked['meta']['install_blocked_reason'])->toContain('not available yet') + ->and($available['meta']['purchase_available'])->toBeTrue() + ->and($available['meta']['install_blocked_reason'])->toBe('Purchase a licence for your organization to install this extension.') + ->and($available['is_installable'])->toBeFalse(); +}); + +test('checkout follows the service feature flag in auto mode and can be forced', function () { + $auto = RegistryServiceFake::config(); + + expect($auto->checkoutEnabled([]))->toBeFalse() + ->and($auto->checkoutEnabled(['checkout' => true]))->toBeTrue() + ->and(RegistryServiceFake::config(['checkout' => 'false'])->checkoutEnabled(['checkout' => true]))->toBeFalse() + ->and(RegistryServiceFake::config(['checkout' => true])->checkoutEnabled([]))->toBeTrue() + ->and(RegistryServiceFake::config(['instance_token' => null, 'checkout' => true])->checkoutEnabled(['checkout' => true]))->toBeFalse() + ->and((new RegistryConfig(['checkout' => true]))->checkoutEnabled(['checkout' => true]))->toBeFalse(); +}); + +test('the publisher portal defaults to the service URL', function () { + expect(RegistryServiceFake::config()->publisherUrl())->toBe(RegistryServiceFake::SERVICE_URL) + ->and(RegistryServiceFake::config(['publisher_url' => 'publish.example.test/'])->publisherUrl())->toBe('https://publish.example.test') + ->and((new RegistryConfig([]))->publisherUrl())->toBeNull(); +}); + +test('releases map with latest, installed and yanked flags', function () { + $mapped = ServiceCatalog::transformReleases(releasesPayload(), '1.0.0'); + + expect($mapped['latest'])->toBe('1.1.0') + ->and($mapped['channels'])->toBe(['stable' => '1.1.0', 'beta' => '1.2.0-beta.1']) + ->and($mapped['releases'])->toHaveCount(3) + ->and($mapped['releases'][0])->toMatchArray(['version' => '1.2.0-beta.1', 'channel' => 'beta', 'is_latest' => false, 'size_bytes' => 2048]) + ->and($mapped['releases'][1])->toMatchArray(['version' => '1.1.0', 'is_latest' => true, 'is_installed' => false, 'is_yanked' => false]) + ->and($mapped['releases'][2])->toMatchArray(['version' => '1.0.0', 'is_installed' => true, 'is_yanked' => true]); +}); + +test('releases are read from the catalog releases endpoint and a missing listing is null', function () { + $this->service + ->respondWith('GET', 'v1/catalog/extensions/registry-demo-hello/releases', 200, releasesPayload()) + ->on('GET', 'v1/catalog/extensions/nope/releases', 'catalog-extension-not-found'); + $client = RegistryClient::fromConfig(RegistryServiceFake::config()); + + expect(ServiceCatalog::releases('registry-demo-hello', $client)['releases'])->toHaveCount(3) + ->and(ServiceCatalog::releases('nope', $client))->toBeNull(); +}); + +test('starting a checkout is refused while the service does not offer it', function () { + $this->service->on('GET', 'v1/instances/me', 'instances-me'); + $config = RegistryServiceFake::config(); + + expect(fn () => ServiceCheckout::start(RegistryClient::fromConfig($config), $config, 'ext_gzh4wgc9nr', 'https://fleet.example.test/extensions')) + ->toThrow(RegistryServiceException::class, 'not available yet'); + expect($this->service->requestsTo('POST', 'v1/instances/me/purchases/checkout'))->toBeEmpty(); +}); + +test('a checkout posts the extension and return URL and returns the hosted URL and hand-off', function () { + $me = RegistryServiceFake::body('instances-me'); + $me['features'] = ['checkout' => true]; + $this->service + ->respondWith('GET', 'v1/instances/me', 200, $me) + ->respondWith('POST', 'v1/instances/me/purchases/checkout', 201, ['url' => 'https://checkout.stripe.test/c/cs_1', 'handoff' => 'hof_1']); + $config = RegistryServiceFake::config(); + + $checkout = ServiceCheckout::start(RegistryClient::fromConfig($config), $config, 'ext_gzh4wgc9nr', 'https://fleet.example.test/extensions?extension_id=ext_gzh4wgc9nr', 'buyer@example.test'); + + $body = $this->service->requestsTo('POST', 'v1/instances/me/purchases/checkout')[0]->data(); + expect($checkout)->toBe(['url' => 'https://checkout.stripe.test/c/cs_1', 'handoff' => 'hof_1']) + ->and($body['extension'])->toBe('ext_gzh4wgc9nr') + ->and($body['return_url'])->toBe('https://fleet.example.test/extensions?extension_id=ext_gzh4wgc9nr') + ->and($body['buyer_email'])->toBe('buyer@example.test'); +}); + +test('a forced checkout against a service without the endpoint reports it as not available', function () { + $this->service + ->on('GET', 'v1/instances/me', 'instances-me') + ->respondWith('POST', 'v1/instances/me/purchases/checkout', 404, ['errors' => ['Not found.']]); + $config = RegistryServiceFake::config(['checkout' => true]); + + try { + ServiceCheckout::start(RegistryClient::fromConfig($config), $config, 'ext_gzh4wgc9nr', 'https://fleet.example.test/extensions'); + $this->fail('expected a RegistryServiceException'); + } catch (RegistryServiceException $e) { + expect($e->status())->toBe(501) + ->and($e->getMessage())->toBe(ServiceCheckout::unavailableReason()); + } +}); + +test('hand-off statuses map onto pending, completed and failed', function () { + expect(ServiceCheckout::describe(['status' => 'open'])['status'])->toBe('pending') + ->and(ServiceCheckout::describe([])['status'])->toBe('pending') + ->and(ServiceCheckout::describe(['status' => 'PAID', 'receipt_url' => 'https://pay.stripe.test/r/1'])['receipt_url'])->toBe('https://pay.stripe.test/r/1') + ->and(ServiceCheckout::describe(['purchase' => ['status' => 'completed', 'extension' => ['id' => 'ext_1']]]))->toMatchArray(['status' => 'completed', 'extension' => ['id' => 'ext_1']]) + ->and(ServiceCheckout::describe(['status' => 'expired', 'message' => 'Session expired'])['status'])->toBe('failed'); +}); + +test('a hand-off is polled uncached and forgets the cached instance state', function () { + $this->service + ->respondWith('GET', 'v1/instances/me/purchases/hof_1', 200, ['status' => 'pending']) + ->thenRespondWith('GET', 'v1/instances/me/purchases/hof_1', 200, ['status' => 'completed']); + $client = RegistryClient::fromConfig(RegistryServiceFake::config()); + + expect($client->purchase('hof_1')['status'])->toBe('pending') + ->and($client->purchase('hof_1')['status'])->toBe('completed') + ->and($this->service->requestsTo('GET', 'v1/instances/me/purchases/hof_1'))->toHaveCount(2); +}); From 2b006ea73025cc2647a0e1f9b7abb8c234704e93 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 23 Oct 2026 15:31:09 +0800 Subject: [PATCH 02/12] feat(console): operator redesign on ember-ui (Workstream N) - One ExtensionCard with the nine canvas states (utils/extension-state), shared with cell/extension-identity and the other table cells - Explore and category on Layout::Resource::CardsGrid with a table toggle, FiltersPicker facets (category, pricing, hosting, hide installed) as chips, sort, skeletons, and empty, no-results and error states - Detail panel on Layout::Resource::Panel with the CTA matrix and Overview, Screenshots, Details and Versions (service releases) tabs, kept in ?extension_id= - One installer modal for install, update and uninstall: confirm, named steps, success with reload countdown, failure with stage, log, CLI fallback, Retry; typed-name guard on uninstall - Purchase: service hosted checkout with Finalizing hand-off polling; blocked with the service reason until checkout is offered; v1 embedded Stripe kept - Installed and Purchased on Layout::Resource::Tabular with grid toggle, activations and receipts - Sidebar categories with counts; Developers panel replaced by a Publisher portal link; review and publish admin panels unregistered - Copy deck in translations/en-us.yaml; 148 Ember tests --- addon/components/cell/extension-action.hbs | 1 + addon/components/cell/extension-action.js | 3 + addon/components/cell/extension-identity.hbs | 8 + addon/components/cell/extension-identity.js | 69 ++++ addon/components/cell/extension-status.hbs | 5 + addon/components/cell/extension-status.js | 49 +++ addon/components/cell/licence-activations.hbs | 5 + addon/components/cell/licence-activations.js | 3 + addon/components/cell/licence-receipt.hbs | 5 + addon/components/cell/licence-receipt.js | 3 + addon/components/cell/release-action.hbs | 8 + addon/components/cell/release-action.js | 3 + addon/components/cell/release-version.hbs | 6 + addon/components/cell/release-version.js | 3 + addon/components/explore-catalog.hbs | 142 +++++++ addon/components/explore-catalog.js | 10 + addon/components/explore-empty-state.hbs | 12 - addon/components/explore-empty-state.js | 20 - addon/components/extension-card.hbs | 66 ++-- addon/components/extension-card.js | 286 ++------------ addon/components/extension-card/action.hbs | 10 + addon/components/extension-card/action.js | 60 +++ addon/components/extension-form.js | 53 +-- addon/components/extension-panel.hbs | 41 ++ addon/components/extension-panel.js | 38 ++ addon/components/extension-panel/details.hbs | 16 + addon/components/extension-panel/details.js | 54 +++ addon/components/extension-panel/header.hbs | 74 ++++ addon/components/extension-panel/header.js | 108 ++++++ addon/components/extension-panel/overview.hbs | 58 +++ addon/components/extension-panel/overview.js | 39 ++ .../extension-panel/screenshots.hbs | 29 ++ .../components/extension-panel/screenshots.js | 52 +++ addon/components/extension-panel/versions.hbs | 19 + addon/components/extension-panel/versions.js | 95 +++++ addon/components/installer-job-error.hbs | 29 -- addon/components/installer-job-error.js | 7 - .../modals/confirm-extension-purchase.hbs | 5 - .../modals/confirm-extension-purchase.js | 3 - addon/components/modals/extension-details.hbs | 110 ------ addon/components/modals/extension-details.js | 33 -- .../components/modals/extension-installer.hbs | 113 ++++++ .../components/modals/extension-installer.js | 76 ++++ .../modals/extension-purchase-form.hbs | 24 +- .../modals/extension-purchase-status.hbs | 34 ++ .../modals/extension-purchase-status.js | 13 + .../components/modals/extension-uninstall.hbs | 28 -- .../components/modals/extension-uninstall.js | 11 - .../self-managed-install-instructions.hbs | 40 -- addon/controllers/application.js | 24 +- addon/controllers/explore/category.js | 40 +- addon/controllers/explore/index.js | 222 ++++++++++- addon/controllers/installed.js | 171 ++++++--- addon/controllers/purchased.js | 101 ++++- addon/extension.js | 14 +- addon/routes/application.js | 31 +- addon/routes/explore/category.js | 47 ++- addon/routes/explore/index.js | 26 +- addon/routes/installed.js | 10 +- addon/routes/purchased.js | 10 +- addon/services/extension-installer.js | 231 ++++++++++++ addon/services/extension-panel.js | 102 +++++ addon/services/extension-purchase.js | 291 +++++++++++++++ addon/services/registry-context.js | 64 ++++ addon/styles/registry-bridge-engine.css | 351 +++++++++++++++++- addon/templates/application.hbs | 64 ++-- addon/templates/explore/category.hbs | 24 +- addon/templates/explore/index.hbs | 24 +- addon/templates/installed.hbs | 95 +++-- addon/templates/purchased.hbs | 76 +++- addon/utils/extension-state.js | 215 +++++++++++ addon/utils/installer-job.js | 94 ++++- .../extension-action.js} | 2 +- .../extension-identity.js} | 2 +- app/components/cell/extension-status.js | 1 + app/components/cell/licence-activations.js | 1 + app/components/cell/licence-receipt.js | 1 + .../release-action.js} | 2 +- app/components/cell/release-version.js | 1 + ...taller-job-error.js => explore-catalog.js} | 2 +- app/components/extension-card/action.js | 1 + app/components/extension-panel.js | 1 + app/components/extension-panel/details.js | 1 + app/components/extension-panel/header.js | 1 + app/components/extension-panel/overview.js | 1 + app/components/extension-panel/screenshots.js | 1 + app/components/extension-panel/versions.js | 1 + .../modals/confirm-extension-purchase.js | 1 - app/components/modals/extension-installer.js | 1 + .../modals/extension-purchase-status.js | 1 + .../self-managed-install-instructions.js | 1 - app/services/extension-installer.js | 1 + app/services/extension-panel.js | 1 + app/services/extension-purchase.js | 1 + app/services/registry-context.js | 1 + tests/helpers/index.js | 144 +++++++ .../cell/extension-identity-test.js | 46 +++ .../components/explore-catalog-test.js | 114 ++++++ .../components/extension-card-test.js | 109 ++++-- .../components/extension-panel/header-test.js | 141 +++++++ .../extension-panel/versions-test.js | 107 ++++++ .../components/installer-job-error-test.js | 41 -- .../modals/confirm-extension-purchase-test.js | 16 - .../modals/extension-details-test.js | 38 -- .../modals/extension-installer-test.js | 85 +++++ .../modals/extension-purchase-status-test.js | 25 ++ .../modals/extension-uninstall-test.js | 17 - .../self-managed-install-instructions-test.js | 17 - tests/unit/controllers/application-test.js | 17 +- tests/unit/controllers/installed-test.js | 35 +- .../unit/services/extension-installer-test.js | 128 +++++++ tests/unit/services/extension-panel-test.js | 42 +++ .../unit/services/extension-purchase-test.js | 126 +++++++ tests/unit/services/registry-context-test.js | 41 ++ tests/unit/utils/extension-state-test.js | 109 ++++++ tests/unit/utils/installer-job-test.js | 97 ++++- translations/en-us.yaml | 295 ++++++++++++++- 117 files changed, 5026 insertions(+), 1097 deletions(-) create mode 100644 addon/components/cell/extension-action.hbs create mode 100644 addon/components/cell/extension-action.js create mode 100644 addon/components/cell/extension-identity.hbs create mode 100644 addon/components/cell/extension-identity.js create mode 100644 addon/components/cell/extension-status.hbs create mode 100644 addon/components/cell/extension-status.js create mode 100644 addon/components/cell/licence-activations.hbs create mode 100644 addon/components/cell/licence-activations.js create mode 100644 addon/components/cell/licence-receipt.hbs create mode 100644 addon/components/cell/licence-receipt.js create mode 100644 addon/components/cell/release-action.hbs create mode 100644 addon/components/cell/release-action.js create mode 100644 addon/components/cell/release-version.hbs create mode 100644 addon/components/cell/release-version.js create mode 100644 addon/components/explore-catalog.hbs create mode 100644 addon/components/explore-catalog.js delete mode 100644 addon/components/explore-empty-state.hbs delete mode 100644 addon/components/explore-empty-state.js create mode 100644 addon/components/extension-card/action.hbs create mode 100644 addon/components/extension-card/action.js create mode 100644 addon/components/extension-panel.hbs create mode 100644 addon/components/extension-panel.js create mode 100644 addon/components/extension-panel/details.hbs create mode 100644 addon/components/extension-panel/details.js create mode 100644 addon/components/extension-panel/header.hbs create mode 100644 addon/components/extension-panel/header.js create mode 100644 addon/components/extension-panel/overview.hbs create mode 100644 addon/components/extension-panel/overview.js create mode 100644 addon/components/extension-panel/screenshots.hbs create mode 100644 addon/components/extension-panel/screenshots.js create mode 100644 addon/components/extension-panel/versions.hbs create mode 100644 addon/components/extension-panel/versions.js delete mode 100644 addon/components/installer-job-error.hbs delete mode 100644 addon/components/installer-job-error.js delete mode 100644 addon/components/modals/confirm-extension-purchase.hbs delete mode 100644 addon/components/modals/confirm-extension-purchase.js delete mode 100644 addon/components/modals/extension-details.hbs delete mode 100644 addon/components/modals/extension-details.js create mode 100644 addon/components/modals/extension-installer.hbs create mode 100644 addon/components/modals/extension-installer.js create mode 100644 addon/components/modals/extension-purchase-status.hbs create mode 100644 addon/components/modals/extension-purchase-status.js delete mode 100644 addon/components/modals/extension-uninstall.hbs delete mode 100644 addon/components/modals/extension-uninstall.js delete mode 100644 addon/components/modals/self-managed-install-instructions.hbs create mode 100644 addon/services/extension-installer.js create mode 100644 addon/services/extension-panel.js create mode 100644 addon/services/extension-purchase.js create mode 100644 addon/services/registry-context.js create mode 100644 addon/utils/extension-state.js rename app/components/{modals/extension-details.js => cell/extension-action.js} (65%) rename app/components/{modals/extension-uninstall.js => cell/extension-identity.js} (64%) create mode 100644 app/components/cell/extension-status.js create mode 100644 app/components/cell/licence-activations.js create mode 100644 app/components/cell/licence-receipt.js rename app/components/{explore-empty-state.js => cell/release-action.js} (69%) create mode 100644 app/components/cell/release-version.js rename app/components/{installer-job-error.js => explore-catalog.js} (69%) create mode 100644 app/components/extension-card/action.js create mode 100644 app/components/extension-panel.js create mode 100644 app/components/extension-panel/details.js create mode 100644 app/components/extension-panel/header.js create mode 100644 app/components/extension-panel/overview.js create mode 100644 app/components/extension-panel/screenshots.js create mode 100644 app/components/extension-panel/versions.js delete mode 100644 app/components/modals/confirm-extension-purchase.js create mode 100644 app/components/modals/extension-installer.js create mode 100644 app/components/modals/extension-purchase-status.js delete mode 100644 app/components/modals/self-managed-install-instructions.js create mode 100644 app/services/extension-installer.js create mode 100644 app/services/extension-panel.js create mode 100644 app/services/extension-purchase.js create mode 100644 app/services/registry-context.js create mode 100644 tests/integration/components/cell/extension-identity-test.js create mode 100644 tests/integration/components/explore-catalog-test.js create mode 100644 tests/integration/components/extension-panel/header-test.js create mode 100644 tests/integration/components/extension-panel/versions-test.js delete mode 100644 tests/integration/components/installer-job-error-test.js delete mode 100644 tests/integration/components/modals/confirm-extension-purchase-test.js delete mode 100644 tests/integration/components/modals/extension-details-test.js create mode 100644 tests/integration/components/modals/extension-installer-test.js create mode 100644 tests/integration/components/modals/extension-purchase-status-test.js delete mode 100644 tests/integration/components/modals/extension-uninstall-test.js delete mode 100644 tests/integration/components/modals/self-managed-install-instructions-test.js create mode 100644 tests/unit/services/extension-installer-test.js create mode 100644 tests/unit/services/extension-panel-test.js create mode 100644 tests/unit/services/extension-purchase-test.js create mode 100644 tests/unit/services/registry-context-test.js create mode 100644 tests/unit/utils/extension-state-test.js diff --git a/addon/components/cell/extension-action.hbs b/addon/components/cell/extension-action.hbs new file mode 100644 index 0000000..393b8c8 --- /dev/null +++ b/addon/components/cell/extension-action.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/extension-action.js b/addon/components/cell/extension-action.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/extension-action.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/extension-identity.hbs b/addon/components/cell/extension-identity.hbs new file mode 100644 index 0000000..f64e225 --- /dev/null +++ b/addon/components/cell/extension-identity.hbs @@ -0,0 +1,8 @@ + diff --git a/addon/components/cell/extension-identity.js b/addon/components/cell/extension-identity.js new file mode 100644 index 0000000..983cf93 --- /dev/null +++ b/addon/components/cell/extension-identity.js @@ -0,0 +1,69 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * An extension in a table cell: icon, name and badges (version, update, + * self-managed, core) on `Table::Cell::Identity`. Clicking opens the detail + * panel unless the column has its own `onClick`. + */ +export default class CellExtensionIdentityComponent extends Component { + @service extensionPanel; + @service intl; + + get extension() { + return this.args.row ?? {}; + } + + /** + * The identity column, with the extension icon fallback from the console + * config (passed in by the template's `config` helper). + */ + columnWith = (fallbackImage) => { + return { + mediaPath: 'icon_url', + labelPath: 'name', + fallbackImage, + showStatusDot: false, + popover: false, + badgeLimit: 3, + badges: () => this.badges, + ...(this.args.column ?? {}), + }; + }; + + get badges() { + const extension = this.extension; + const version = extension.installed_version || extension.current_bundle_version || extension.version; + const badges = []; + if (version) { + badges.push({ key: 'version', label: `v${version}` }); + } + if (extension.update_available === true && extension.latest_version) { + badges.push({ + key: 'update', + label: this.intl.t('registry-bridge.card.update', { version: extension.latest_version }), + class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + }); + } + if (extension.self_managed === true) { + badges.push({ key: 'self-managed', label: this.intl.t('registry-bridge.card.self-managed') }); + } + if (extension.core_service === true || extension.core_extension === true) { + badges.push({ key: 'core', label: this.intl.t('registry-bridge.installed.status.core') }); + } + + return badges; + } + + @action open(extension, event) { + if (typeof this.args.column?.onClick === 'function') { + return; + } + if (typeof this.args.onClick === 'function') { + return this.args.onClick(extension, event); + } + + return this.extensionPanel.open(extension ?? this.extension); + } +} diff --git a/addon/components/cell/extension-status.hbs b/addon/components/cell/extension-status.hbs new file mode 100644 index 0000000..7885bcd --- /dev/null +++ b/addon/components/cell/extension-status.hbs @@ -0,0 +1,5 @@ +
+ {{#each this.badges as |badge|}} + {{badge.text}} + {{/each}} +
diff --git a/addon/components/cell/extension-status.js b/addon/components/cell/extension-status.js new file mode 100644 index 0000000..b328998 --- /dev/null +++ b/addon/components/cell/extension-status.js @@ -0,0 +1,49 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import formatCurrency from '@fleetbase/ember-ui/utils/format-currency'; +import { cardModel } from '../../utils/extension-state'; + +/** + * Status badges for an extension row. `@column.variant`: + * + * - `catalog` (default): price and the card's state badges, as on Explore + * - `installed`: Up to date, Update available or Core service + * - `purchased`: Installed vX, Ready to install or Self-hosted install + */ +export default class CellExtensionStatusComponent extends Component { + @service intl; + + get extension() { + return this.args.row ?? {}; + } + + get badges() { + const extension = this.extension; + const t = (key, params) => this.intl.t(`registry-bridge.${key}`, params); + + switch (this.args.column?.variant) { + case 'installed': + if (extension.core_service === true || extension.core_extension === true) { + return [{ text: t('installed.status.core'), status: 'default' }]; + } + return extension.update_available === true + ? [{ text: t('installed.status.update-available'), status: 'info' }] + : [{ text: t('installed.status.up-to-date'), status: 'success' }]; + case 'purchased': + if (extension.is_installed === true) { + return [{ text: t('purchased.status.installed', { version: extension.installed_version || extension.current_bundle_version || '' }).trim(), status: 'success' }]; + } + if (extension.self_managed === true) { + return [{ text: t('purchased.status.self-hosted'), status: 'default' }]; + } + return [{ text: t('card.ready-to-install'), status: 'info' }]; + default: { + const model = cardModel(extension); + const price = model.priceBadge.label + ? { text: this.intl.t(model.priceBadge.label.key, model.priceBadge.label.params), status: model.priceBadge.status } + : { text: formatCurrency(model.priceBadge.amount, model.priceBadge.currency), status: model.priceBadge.status }; + return [price, ...model.badges.map((badge) => ({ text: this.intl.t(badge.label.key, badge.label.params), status: badge.status }))]; + } + } + } +} diff --git a/addon/components/cell/licence-activations.hbs b/addon/components/cell/licence-activations.hbs new file mode 100644 index 0000000..7281a58 --- /dev/null +++ b/addon/components/cell/licence-activations.hbs @@ -0,0 +1,5 @@ +{{#if @row.meta.licence.max_activations}} + {{t "registry-bridge.purchased.activations" used=(or @row.meta.licence.seats_used 0) max=@row.meta.licence.max_activations}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/licence-activations.js b/addon/components/cell/licence-activations.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/licence-activations.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/licence-receipt.hbs b/addon/components/cell/licence-receipt.hbs new file mode 100644 index 0000000..ddb28df --- /dev/null +++ b/addon/components/cell/licence-receipt.hbs @@ -0,0 +1,5 @@ +{{#if @row.meta.licence.receipt_url}} + {{t "registry-bridge.purchased.receipt"}} +{{else}} + - +{{/if}} diff --git a/addon/components/cell/licence-receipt.js b/addon/components/cell/licence-receipt.js new file mode 100644 index 0000000..fa85053 --- /dev/null +++ b/addon/components/cell/licence-receipt.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/cell/release-action.hbs b/addon/components/cell/release-action.hbs new file mode 100644 index 0000000..042e3b6 --- /dev/null +++ b/addon/components/cell/release-action.hbs @@ -0,0 +1,8 @@ +