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
12 changes: 12 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Web Console preserves compatible API paths, schema and resource names, action na

Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced.

Candidate `1.6.178` handles only the exact `inactive` environment state specially,
after global project and member authorization succeeds. Network and
policy-manager values remain unavailable (`null`), with a state-specific
localized explanation; they are not invented empty resource collections.
Project metadata, membership and removal capabilities remain sourced from the
API. A stale editable network cannot be persisted from that inactive form.
All other states retain the existing scoped reads and error semantics. Backend
authorization, session generation/mutex, MFA, OIDC, workspace lifecycle and
dependencies are unchanged. Formal publication and deployed native acceptance
are pending; historical HOLDs and the incomplete full matrix remain unchanged.
See the [candidate release note](docs/releases/web-console-1.6.178.md).

Published `1.6.177` treats an ended workspace entry as terminal across both logs
and terminal components. Late responses and queued socket/timer callbacks are
bound to the original entry, never an explicitly opened replacement. Existing
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz

## Project status

Candidate `1.6.178` fixes viewing and editing an inactive environment. Once the
globally authorized project and members have loaded, the page does not request
network or policy-manager data scoped to that inactive environment: the API
correctly denies those requests. The existing form explains that network
settings are unavailable until activation, in all thirteen packaged locales.
Metadata and membership controls still follow their original API action links;
cached network data cannot enable a network save. Other states and authorization
failures retain the established error handling. This candidate changes no
backend, authentication, session ownership or dependency graph. Formal
publication and deployed native acceptance remain pending; it does not promote
earlier HOLD results or claim completion of the full matrix. See the
[candidate release note](docs/releases/web-console-1.6.178.md).

Published `1.6.177` prevents ended log and terminal workspace entries from
reconnecting after remount/reload or from late access-ticket, broker, socket
and timer callbacks. Both components share one lifecycle boundary; asynchronous
Expand Down
5 changes: 3 additions & 2 deletions app/components/view-edit-project/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default Component.extend(NewOrEdit, Sortable, {
originalProject: null,
allProjects: null,
policyManager: null,
networkUnavailableForInactiveProject: false,
editing: false,
tab: 'access',

Expand Down Expand Up @@ -150,8 +151,8 @@ export default Component.extend(NewOrEdit, Sortable, {
}.property('project.id', 'project.actionLinks.setmembers'),

canEditNetwork: function() {
return !!this.get('network.actionLinks.update') && !this.get('missingManager') && !this.get('hasUnsupportedPolicy');
}.property('network.actionLinks.update', 'missingManager', 'hasUnsupportedPolicy'),
return !this.get('networkUnavailableForInactiveProject') && !!this.get('network.actionLinks.update') && !this.get('missingManager') && !this.get('hasUnsupportedPolicy');
}.property('networkUnavailableForInactiveProject', 'network.actionLinks.update', 'missingManager', 'hasUnsupportedPolicy'),

canSave: function() {
return this.get('canEditProject') ||
Expand Down
6 changes: 5 additions & 1 deletion app/components/view-edit-project/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@
</section>


{{#if this.network}}
{{#if this.networkUnavailableForInactiveProject}}
<p class="help-block" data-test-inactive-network-notice>
{{t 'viewEditProject.networkPolicy.inactive'}}
</p>
{{else if this.network}}
<section>
<h4>{{t 'viewEditProject.networkPolicy.label'}}</h4>
<hr/>
Expand Down
17 changes: 13 additions & 4 deletions app/settings/projects/detail/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,23 @@ export default Route.extend(PromiseToCb, {
}
);
})],
networks: ['project', this.toCb(() => {
networks: ['importMembers', this.toCb((results) => {
// Inactive environments remain globally visible to their owners,
// but the API correctly rejects requests scoped to them.
if ( results.project.get('state') === 'inactive' ) {
return null;
}
return userStore.find('network', null, {
filter: {accountId: params.project_id},
headers: {[C.HEADER.PROJECT_ID]: params.project_id},
}).then(null, (err) => {
throw this.environmentLoadError(err, 'viewEditProject.error.relatedUnavailable');
});
})],
policyManagers: ['project', this.toCb(() => {
policyManagers: ['importMembers', this.toCb((results) => {
if ( results.project.get('state') === 'inactive' ) {
return null;
}
return userStore.find('stack', null, policyManagerOpt).then(null, (err) => {
throw this.environmentLoadError(err, 'viewEditProject.error.relatedUnavailable');
});
Expand All @@ -75,7 +83,7 @@ export default Route.extend(PromiseToCb, {
}, 'Load all the things');

return promise.then((hash) => {
let network = hash.networks.find((x) => C.PROJECT.SUPPORTS_NETWORK_POLICY.includes(x.get('name')));
let network = hash.networks ? hash.networks.find((x) => C.PROJECT.SUPPORTS_NETWORK_POLICY.includes(x.get('name'))) : null;
if ( network ) {
network = network.clone();

Expand Down Expand Up @@ -106,7 +114,8 @@ export default Route.extend(PromiseToCb, {
let out = EmberObject.create({
all: hash.allProjects,
network: network,
policyManager: hash.policyManagers.objectAt(0),
policyManager: hash.policyManagers ? hash.policyManagers.objectAt(0) : null,
networkUnavailableForInactiveProject: hash.project.get('state') === 'inactive',
});

if ( params.editing ) {
Expand Down
1 change: 1 addition & 0 deletions app/settings/projects/detail/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
initialStacks=this.model.stacks
serviceChoices=this.model.serviceChoices
policyManager=this.model.policyManager
networkUnavailableForInactiveProject=this.model.networkUnavailableForInactiveProject
showEdit=this.editing
editing=true
tab=this.tab
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.177",
"version": "1.6.178",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pasturestack/web-console",
"version": "1.6.177",
"version": "1.6.178",
"license": "Apache-2.0",
"dependencies": {
"sass": "1.103.1"
Expand Down
37 changes: 37 additions & 0 deletions docs/releases/web-console-1.6.178.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Web Console 1.6.178 — inactive environment details

Status: source candidate; formal publication and deployed native acceptance
are pending. No earlier HOLD is promoted to PASS.

## Cause and correction

An inactive environment remains globally visible to an authorized owner, but
the engine rejects API requests scoped to that inactive environment. The
details route unconditionally requested networks and policy-manager stacks;
their 403 responses prevented its model from committing and obscured otherwise
authorized metadata and membership information.

The route now waits for globally authorized project and membership reads,
then skips only those two inapplicable scoped reads when the project state is
exactly `inactive`. It returns `null` for the unavailable data and a dedicated
reason flag. The existing details component displays a translated explanation
in all thirteen packaged locales and prevents a cached network from enabling network saves.
Metadata, member and removal action links are unchanged. Active and transitional
states, denied global data, expired sessions and other failures keep the
existing authorization and error behavior.

## Change boundary and verification

Changes are confined to the details route/template, shared details component,
thirteen translations and their three focused test modules. Release-version metadata
and the matching reviewed lock baseline move together; dependencies do not
change. No API, engine, provider, HAProxy, authentication or session contract is
modified. The existing ended log/terminal and cross-tab session tests are
retained.

Added regressions cover inactive view/edit, globally denied project/member
reads, non-inactive scoped denial, the actual details-template reason flag and
stale-network write prevention. Formal exact-source tests, reproducible static
archives, immutable publication and isolated native view/edit/reload/removal
acceptance must be recorded separately. Full role/resource/locale acceptance
remains incomplete.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasturestack/web-console",
"version": "1.6.177",
"version": "1.6.178",
"private": true,
"description": "PastureStack browser console for the compatible control platform.",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-modernization-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f:
print(json.load(f).get('version', ''))
PY
)
if [[ "$version" != "1.6.177" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.177"
if [[ "$version" != "1.6.178" ]]; then
echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.178"
failures=$((failures + 1))
fi

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-ui-console-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI
fi

printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \
1.6.177 browser-session broker-broadcast
1.6.178 browser-session broker-broadcast
2 changes: 1 addition & 1 deletion scripts/check-ui-critical-high-dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ if lock_bytes != baseline_bytes:
lock = json.loads(lock_bytes)
packages = lock.get("packages", {})
root = packages.get("", {})
if package.get("version") != "1.6.177":
if package.get("version") != "1.6.178":
fail(f"unexpected Web Console version: {package.get('version')}")
if root.get("version") != package.get("version"):
fail(f"lock root version differs: {root.get('version')}")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { A } from '@ember/array';
import Component from '@ember/component';
import EmberRouter from '@ember/routing/router';
import EmberObject, { get } from '@ember/object';
import Service from '@ember/service';
import Service, { service } from '@ember/service';
import { precompileTemplate } from '@ember/template-compilation';
import { click, find, findAll, render, select, settled, setupContext, setupRenderingContext, teardownContext } from '@ember/test-helpers';
import { module, test } from 'qunit';

import { initialize as initializePodLayouts } from 'ui/initializers/pod-component-layouts';
import ViewEditProject from 'ui/components/view-edit-project/component';
import ProjectTemplate from 'ui/models/projecttemplate';
import Router from 'ui/router';
import ProjectDetailTemplate from 'ui/settings/projects/detail/template';
import { destroyOwned } from '../../helpers/owned-subject';
import resolver from '../../helpers/resolver';

Expand Down Expand Up @@ -218,6 +221,63 @@ module('Integration | Component | view edit project permissions', function(hooks
assert.strictEqual(findAll('.footer-actions button').length, 2);
});

for (let editing of [false, true]) {
test(`inactive detail ${editing ? 'edit' : 'view'} shows its state reason without scoped controls`, async function(assert) {
// Use an isolated route map; the application's extension registry is
// intentionally consumed only once by its native Router.
let DetailRouter = EmberRouter.extend({location: 'none'});
DetailRouter.map(function() {
this.route('settings', function() {
this.route('projects', {path: '/env'}, function() {
this.route('detail', {path: '/:project_id'});
});
});
});
this.owner.register('router:main', DetailRouter);
this.owner.lookup('router:main').setupRouter();
this.owner.register('component:action-menu', Component.extend({
layout: precompileTemplate('<span data-test-action-menu>{{this.model.actionLinks.remove}}</span>'),
}));
this.owner.register('component:header-state', Component.extend({
layout: precompileTemplate('<span data-test-header-state>{{this.model.state}}</span>'),
}));
this.owner.register('component:power-select', Component.extend({
layout: precompileTemplate('<span></span>'),
}));
let actionLinks = {update: '/projects/1a21', setmembers: '/projects/1a21?action=setmembers', remove: '/projects/1a21'};
this.project.setProperties({state: 'inactive', actionLinks});
this.network.set('actionLinks', {update: '/networks/1n1'});
this.model = EmberObject.create({
project: this.project, originalProject: this.originalProject, all: A([this.project]),
network: this.network, policyManager: this.policyManager, networkUnavailableForInactiveProject: true,
});
this.editing = editing;
this.done = () => {};
this.cancel = () => {};
this.owner.register('service:user-store', Service.extend(this.userStore));
// Rendering owners do not run the application's store initializer.
// Supply its real service injection without replacing component logic.
this.owner.register('component:view-edit-project', ViewEditProject.extend({userStore: service('user-store')}));
// Render the actual detail template so its reason flag forwarding is tested.
await render(ProjectDetailTemplate);

assert.ok(find('[data-test-inactive-network-notice]').textContent.includes('viewEditProject.networkPolicy.inactive'), 'a state-specific translated explanation is visible');
assert.ok(find('[data-test-member-name]'), 'global membership data stays visible');
assert.strictEqual(findAll('.radio input').length, 0, 'cached network data cannot expose policy controls');
assert.notOk(find('[data-test-network-only-edit]'), 'no scoped network edit link is introduced');
assert.strictEqual(this.project.get('actionLinks'), actionLinks, 'existing global capabilities are untouched');
if ( editing ) {
assert.false(find('input[type="text"]').disabled, 'global metadata capability still enables its fields');
assert.ok(find('[data-test-member-add]'), 'global member capability remains available');
assert.strictEqual(findAll('table.grid select').length, 1, 'member roles remain editable');
assert.strictEqual(findAll('.footer-actions button').length, 2, 'global save and cancel remain available');
} else {
assert.strictEqual(find('[data-test-header-state]').textContent.trim(), 'inactive', 'the environment state remains visible');
assert.strictEqual(find('[data-test-action-menu]').textContent.trim(), actionLinks.remove, 'the existing action menu receives the original global remove link');
}
});
}

test('network-only environment can reach its edit form from the detail header', async function(assert) {
this.owner.register('router:main', Router);
this.owner.register('component:action-menu', Component.extend({
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/components/view-edit-project-permissions-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,37 @@ test('member actions cannot mutate a readonly membership list', function(assert)
destroyOwned(component);
});

test('inactive network unavailability preserves global capabilities and prevents stale network saves', async function(assert) {
for (let canEdit of [false, true]) {
let writes = {project: 0, members: 0, network: 0};
let actionLinks = canEdit ? {update: '/projects/1a21', setmembers: '/projects/1a21?action=setmembers', remove: '/projects/1a21'} : {};
let project = EmberObject.create({
id: '1a21', state: 'inactive', actionLinks,
projectMembers: A([EmberObject.create({externalIdType: 'oidc_user', externalId: 'owner-1', role: 'owner'})]),
validationErrors() { return A([]); },
save() { writes.project++; return resolve(this); },
doAction(action) { assert.strictEqual(action, 'setmembers'); writes.members++; return resolve(); },
});
let network = EmberObject.create({
actionLinks: {update: '/networks/1n1'}, policy: A([]),
save() { writes.network++; return resolve(this); },
});
let component = makeComponent(project, network, {networkUnavailableForInactiveProject: true});

try {
assert.strictEqual(component.get('canEditProject'), canEdit, 'metadata still follows the global project link');
assert.strictEqual(component.get('canEditMembers'), canEdit, 'members still follow the global setmembers link');
assert.false(component.get('canEditNetwork'), 'even a stale editable network cannot enable scoped persistence');
assert.strictEqual(component.get('canSave'), canEdit, 'the notice does not grant a save capability');
await component.get('actions').save.call(component);
assert.deepEqual(writes, {project: Number(canEdit), members: Number(canEdit), network: 0}, 'only the advertised global operations are saved');
assert.strictEqual(project.get('actionLinks'), actionLinks, 'remove and other action links are not rewritten');
} finally {
destroyOwned(component);
}
}
});

test('member validation errors use translated messages', function(assert) {
let owner = EmberObject.create({externalIdType: 'oidc_user', externalId: 'owner-1', role: 'owner'});
let project = EmberObject.create({
Expand Down
Loading
Loading