Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/11627-offline-local-package-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
---

On a runtime with no marketplace that still mounts install-local (an offline boot, `OS_CLOUD_URL=off`), a local install's Details page now offers its local menu: re-seed sample data, purge sample data and uninstall from this runtime (objectui#11627).

Installed Apps links each local install's Details to the marketplace package page. That page answered a marketplace-off runtime with the "App Marketplace is turned off" notice and nothing else, so the local menu below that check could not be reached and no install-local request was ever issued. Now, when the runtime config reports `features.installLocal` and the viewer is an admin, the page draws the package's header (manifest id and installed version) and its local menu above the same notice. The menu issues the same `POST /api/v1/marketplace/install-local/MANIFEST_ID/reseed-sample-data` and `.../purge-sample-data` calls as on a marketplace-on runtime. The page finds the install by the id Installed Apps put in the link (the ledger entry's `packageId`), and the menu acts on its manifest id.

Everything that needs a marketplace stays refused as before: the page sends no catalog request and no cloud-installation probe, and it draws no install-to-cloud button, readme or version list. A viewer who is not an admin, a runtime that does not mount install-local, and a package that is not a local install all see the notice alone, as before.

**Clause-②: no.** Nothing on the package entry changes: no export, prop, type member or i18n key is added. `MarketplacePackagePage` takes no props, and the strings it draws already existed.
237 changes: 148 additions & 89 deletions packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,21 +166,21 @@ export function MarketplacePackagePage() {
// with no marketplace proxy at all), so it stays its own check rather than
// being folded into the other two predicates.
if (!getRuntimeConfig().features.installLocal) return;
// Its only consumer is `localInstalls.find(...)` in the content branch
// below, which is unreachable whenever the page has already returned
// `MarketplaceDisabled` or (post-objectui#5583) `MarketplaceAccessDenied`.
// Firing anyway would be the same discarded-request class `2573ff434`
// closed for this page, on the flag that change was not about
// (objectui#5620).
if (!marketplaceEnabled) return;
// Its only consumer is the `localInstall` match below, read by the content
// branch AND by the marketplace-off local view (objectui#11627). Neither
// is drawn for a viewer refused the admin surface, so firing for one would
// be the same discarded-request class `2573ff434` closed for this page
// (objectui#5620). `marketplaceEnabled` is deliberately NOT a gate any
// more: on a runtime with no marketplace but a mounted install-local
// surface, this answer is what decides whether the local menu is drawn.
if (!isAdmin) return;
let cancelled = false;
(async () => {
const items = await listLocalInstalls();
if (!cancelled) setLocalInstalls(items);
})();
return () => { cancelled = true; };
}, [packageId, localResult, marketplaceEnabled, isAdmin]);
}, [packageId, localResult, isAdmin]);

// Seed cloud-install state so the primary CTA renders as "Installed" on
// first paint instead of inviting another install.
Expand Down Expand Up @@ -555,6 +555,103 @@ export function MarketplacePackagePage() {
}
};

// `features.installLocal` is the server's own answer to "is the install-local
// surface mounted here" -- the same read the effect above gates on, never a
// guess from the package's fields.
const supportsLocal = getRuntimeConfig().features.installLocal;
// Which local install, if any, this page is about. With a marketplace the
// catalog row names it, by manifest id. Without one there is no catalog row,
// and the route's id is the one Installed Apps built this link from
// (`entry.packageId`), so it is matched on that same field. Declared ahead of
// every early return: the local sample-data handlers above close over it.
const localInstall = (marketplaceEnabled
? localInstalls.find((i) => !!data && i.manifestId === data.package.manifest_id)
: localInstalls.find((i) => i.packageId === packageId)) ?? null;

// The local-install menu and the two status banners, drawn by both the
// catalog view and the marketplace-off local view below (objectui#11627).
const localInstalledBadge = localInstall && (
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{t('marketplace.detail.installedV', { version: localInstall.version })}
</Badge>
);
const localMenu = localInstall && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="px-2.5" aria-label={t('marketplace.detail.moreOptions')}>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
{sampleDataBusy === 'reseed'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
{localInstall.withSampleData
? t('marketplace.detail.reseedAgain')
: t('marketplace.detail.addSampleData')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={doPurgeLocalSampleData}
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
className="text-destructive focus:text-destructive"
>
{sampleDataBusy === 'purge'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
{t('marketplace.detail.purgeSampleData')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={doUninstallLocal} disabled={installingLocal} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />
{t('marketplace.detail.uninstallFromRuntime')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const sampleDataNote = sampleDataMsg && (
<div
role="status"
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${sampleDataMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
>
{sampleDataMsg.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">{sampleDataMsg.text}</div>
<button
type="button"
className="text-xs underline opacity-60 hover:opacity-100"
onClick={() => setSampleDataMsg(null)}
>
{t('marketplace.action.dismiss')}
</button>
</div>
);
// `suggestFor` is the manifest id whose suggested audience bindings a
// successful local INSTALL surfaces (ADR-0090 D5), or `null` where no install
// door is drawn and the only result this banner can carry is an uninstall's.
const localResultNote = (suggestFor: string | null) => localResult && (
<div
role="status"
className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
>
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">
{localResult.message}
{/* ADR-0090 D5 — local installs land in this runtime's kernel, so
its suggested audience bindings are confirmable right here. */}
{localResult.ok && suggestFor ? (
<SuggestedBindingsPanel packageId={suggestFor} strings={suggestionStrings} className="mt-2" />
) : null}
</div>
<button
type="button"
className="text-xs underline opacity-60 hover:opacity-100"
onClick={() => setLocalResult(null)}
>
{t('marketplace.action.dismiss')}
</button>
</div>
);

// A CONFIGURATION CONCLUSION, not a load failure -- the same informational
// state the catalog page renders, so the two pages stop disagreeing about the
// same runtime (`2573ff434`). Reached by a pasted or bookmarked package URL,
Expand All @@ -565,7 +662,45 @@ export function MarketplacePackagePage() {
// `features.marketplace` is public runtime config that any client already
// reads. Telling an unprivileged operator they lack permission for a surface
// that exists for nobody is the same misdirection this fix removes.
if (!marketplaceEnabled) return <MarketplaceDisabled />;
//
// objectui#11627 — that answer covers the REMOTE half of the page only. A
// runtime with no marketplace can still mount the install-local surface (an
// offline boot, `OS_CLOUD_URL=off`), and Installed Apps links each local
// install's Details here; the notice used to be all that page showed, so
// local reseed / purge had no way in. Now an admin whose package IS a local
// install also gets its header and local menu, ABOVE the same notice: the
// catalog fetch, the install-to-cloud CTA, the readme and the version list
// stay refused exactly as before, and every other viewer sees the notice
// alone. The local half is ADDED once `listLocalInstalls` answers, so the
// notice is never painted and then retracted.
if (!marketplaceEnabled) {
if (!(supportsLocal && isAdmin && (localInstall || localResult))) return <MarketplaceDisabled />;
return (
<div className="mx-auto w-full max-w-6xl flex flex-col gap-6 p-4 sm:p-6">
{localInstall && (
<div className="flex items-start gap-5 flex-wrap sm:flex-nowrap rounded-2xl border bg-gradient-to-br from-primary/5 via-background to-background p-6 sm:p-8">
<PackageIcon
manifestId={localInstall.manifestId}
className="h-20 w-20 rounded-2xl shadow-sm ring-1 ring-border shrink-0"
initialClassName="text-3xl font-bold"
/>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight truncate">{localInstall.manifestId}</h1>
<div className="text-sm text-muted-foreground mt-2 flex flex-wrap items-center gap-1.5">
{localInstalledBadge}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-start">
{localMenu}
</div>
</div>
)}
{sampleDataNote}
{localResultNote(null)}
<MarketplaceDisabled />
</div>
);
}

// Ahead of BOTH the loading and the load-failure branches below
// (objectui#5583): authorization is not a function of whether the fetch
Expand Down Expand Up @@ -630,7 +765,6 @@ export function MarketplacePackagePage() {
const pkg = data.package;
const loc = localizePackage(pkg as any, language);
const latestVersion = pkg.latest_version?.version ?? data.versions[0]?.version ?? null;
const localInstall = localInstalls.find((i) => i.manifestId === pkg.manifest_id) ?? null;
// PD4 (ADR-0025 §3.11): code-bearing packages must disclose + be acknowledged.
const containsCode = !!pkg.latest_version?.contains_code;
// ADR-0010 version lifecycle: installed cloud env is on an OLDER version than
Expand All @@ -643,7 +777,6 @@ export function MarketplacePackagePage() {
&& !!latestVersion
&& isNewerVersion(latestVersion, cloudInstalledVersion);

const supportsLocal = getRuntimeConfig().features.installLocal;
const primaryDisabled = !latestVersion || installingLocal || installing || (!supportsLocal && !!cloudInstalledVersion && !cloudUpdateAvailable);
const primaryAction = supportsLocal
? {
Expand Down Expand Up @@ -713,12 +846,7 @@ export function MarketplacePackagePage() {
)}
{categoryLabel && <Badge variant="outline">{categoryLabel}</Badge>}
{pkg.license && <Badge variant="outline" className="font-normal">{pkg.license}</Badge>}
{localInstall && (
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
{t('marketplace.detail.installedV', { version: localInstall.version })}
</Badge>
)}
{localInstalledBadge}
{!localInstall && cloudInstalledVersion && (
<Badge variant="default" className="bg-green-600 hover:bg-green-600 gap-1">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
Expand All @@ -741,39 +869,7 @@ export function MarketplacePackagePage() {
<Download className="h-4 w-4 mr-1.5" aria-hidden="true" />
{primaryAction.label}
</Button>
{localInstall && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="px-2.5" aria-label={t('marketplace.detail.moreOptions')}>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={doReseedLocalSampleData} disabled={sampleDataBusy !== null}>
{sampleDataBusy === 'reseed'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Database className="h-4 w-4 mr-2" aria-hidden="true" />}
{localInstall.withSampleData
? t('marketplace.detail.reseedAgain')
: t('marketplace.detail.addSampleData')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={doPurgeLocalSampleData}
disabled={sampleDataBusy !== null || !localInstall.withSampleData}
className="text-destructive focus:text-destructive"
>
{sampleDataBusy === 'purge'
? <Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
: <Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />}
{t('marketplace.detail.purgeSampleData')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={doUninstallLocal} disabled={installingLocal} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" aria-hidden="true" />
{t('marketplace.detail.uninstallFromRuntime')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{localMenu}
{/* Reseed / purge still POST cross-origin to the control plane, which
the browser blocks on a tenant subdomain. Only offer them when the
runtime IS the cloud (same-origin). On tenants the install state is
Expand Down Expand Up @@ -811,46 +907,9 @@ export function MarketplacePackagePage() {
</div>
</div>

{sampleDataMsg && (
<div
role="status"
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${sampleDataMsg.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
>
{sampleDataMsg.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">{sampleDataMsg.text}</div>
<button
type="button"
className="text-xs underline opacity-60 hover:opacity-100"
onClick={() => setSampleDataMsg(null)}
>
{t('marketplace.action.dismiss')}
</button>
</div>
)}
{sampleDataNote}

{localResult && (
<div
role="status"
className={`flex items-start gap-2 rounded-md border p-3 text-sm whitespace-pre-wrap ${localResult.ok ? 'border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400' : 'border-destructive/30 bg-destructive/5 text-destructive'}`}
>
{localResult.ok ? <CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" /> : <AlertCircle className="h-4 w-4 mt-0.5 shrink-0" aria-hidden="true" />}
<div className="flex-1">
{localResult.message}
{/* ADR-0090 D5 — local installs land in this runtime's kernel, so
its suggested audience bindings are confirmable right here. */}
{localResult.ok && (
<SuggestedBindingsPanel packageId={pkg.manifest_id} strings={suggestionStrings} className="mt-2" />
)}
</div>
<button
type="button"
className="text-xs underline opacity-60 hover:opacity-100"
onClick={() => setLocalResult(null)}
>
{t('marketplace.action.dismiss')}
</button>
</div>
)}
{localResultNote(pkg.manifest_id)}

<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-4">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
* `features.installLocal: true`, the request still fired — and its answer
* was discarded — on a marketplace-off runtime and for a refused viewer.
*
* ## objectui#11627 retired the `marketplaceEnabled` half
*
* On a marketplace-OFF runtime that mounts install-local, the answer now HAS a
* consumer: an admin whose package is a local install gets its local menu
* above the disabled notice, and this request is what decides that. So the
* case that pinned "never fires on a marketplace-off runtime, even for an
* admin with installLocal on" pinned exactly the branch that card removed, and
* was replaced, not respelled. What still holds on a marketplace-off runtime,
* and is pinned below, is the rest of this card's class: no request where its
* answer has nowhere to go — installLocal off, or a refused viewer. The
* positive half lives in `MarketplacePackagePage.offlineLocalMenu-11627.test.tsx`.
*
* ## What is NOT claimed
*
* That `features.installLocal` stops mattering. It is a genuinely separate
Expand Down Expand Up @@ -148,8 +160,18 @@ afterEach(() => {
});

describe('listLocalInstalls on a marketplace-OFF runtime', () => {
it('never fires, even with installLocal on and the viewer an admin', async () => {
it('never fires when installLocal is off too, even for an admin', async () => {
viewer.isAdmin = true;
await bootOn(serverConfig(false, false));

render(<MarketplacePackagePage />);

await waitFor(() => expect(screen.getByTestId('marketplace-disabled')).toBeInTheDocument());
expect(listLocalInstalls).not.toHaveBeenCalled();
});

it('never fires for a refused (non-admin) viewer, even with installLocal on', async () => {
viewer.isAdmin = false;
await bootOn(serverConfig(false, true));

render(<MarketplacePackagePage />);
Expand Down
Loading
Loading