diff --git a/README.md b/README.md index 6be73294c1..3f1535c337 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current source compatibility target is `1.6.152`. It retains the existing Node 24, Ember, Sass, +The current source compatibility target is `1.6.153`. It retains the existing Node 24, Ember, Sass, dependency, browser-smoke, terminal, console, and test-harness modernization. It adds a provider-neutral OpenID Connect administration and sign-in flow with PKCE S256, staged configuration validation, a real test login before @@ -16,6 +16,16 @@ activation, and local-authentication recovery. Product-owned names, logos, icons, package metadata, and visible text use PastureStack branding. API models and protocol fields remain compatible. +Release `1.6.153` makes Stack, Service, and Container write controls check +their current project/resource capability when the user acts; delayed project +upgrades and service scaling cannot carry a click into a different selected +project. It improves Registry edit recovery, localized errors and Japanese +form labels, and shows an unavailable state when host/container monitoring +cannot connect instead of leaving a spinner. These are browser-console +changes, not a substitute for Server-side per-resource authorization. See +the [release note](docs/releases/web-console-1.6.153.md) for test evidence +and the remaining 8080 acceptance boundary. + Release `1.6.152` uses each Stack, Service, and Container form's visible, translated name label in its required-field error. This closes the Chinese/Japanese Stack mismatch observed on isolated Server v1.6.485 QA; diff --git a/app/catalog-tab/launch/route.js b/app/catalog-tab/launch/route.js index 58546c6186..d8dcd5f950 100644 --- a/app/catalog-tab/launch/route.js +++ b/app/catalog-tab/launch/route.js @@ -4,7 +4,7 @@ import Route from '@ember/routing/route'; import EmberObject, { get } from '@ember/object'; import C from 'ui/utils/constants'; import { catalogVersionOptions } from 'ui/utils/catalog-version-options'; -import RequireCreatePermission from 'ui/mixins/require-create-permission'; +import Errors from 'ui/utils/errors'; function resourceValue(resource, path) { if ( !resource ) { @@ -18,33 +18,63 @@ function resourceValue(resource, path) { return get(resource, path); } -export default Route.extend(RequireCreatePermission, { +export default Route.extend({ catalog: service(), - - requiredCreateType: 'stack', - requiredUpdateType: 'stack', - updateWhenQueryParam: 'upgrade', + intl: service(), + projects: service(), parentRoute: 'catalog-tab', + unavailable(key, status=403) { + return {status, code: status === 404 ? 'NotFound' : 'Forbidden', messageKey: key, message: this.get('intl').t(key)}; + }, + + fetchTemplate(id, upgrade=false) { + return this.get('catalog').fetchTemplate(id, upgrade).catch((err) => { + let status = Errors.status(err); + if ( status === 403 || status === 404 ) { + throw this.unavailable(upgrade ? 'newCatalog.upgradeUnavailable' : 'newCatalog.templateUnavailable', 404); + } + throw err; + }); + }, + model: function(params/*, transition*/) { var store = this.get('store'); + let projectId = this.get('projects.current.id'); + + if ( !params.stackId && !this.get('projects').canCreateResource('stack') ) { + throw this.unavailable('newCatalog.permissionDenied'); + } + if ( params.upgrade && !params.stackId ) { + throw this.unavailable('newCatalog.upgradeUnavailable'); + } var dependencies = { - tpl: this.get('catalog').fetchTemplate(params.template), + tpl: this.fetchTemplate(params.template), }; if ( params.upgrade ) { - dependencies.upgrade = this.get('catalog').fetchTemplate(params.upgrade, true); + dependencies.upgrade = this.fetchTemplate(params.upgrade, true); } if ( params.stackId ) { - dependencies.stack = store.find('stack', params.stackId); + dependencies.stack = store.find('stack', params.stackId).catch((err) => { + let status = Errors.status(err); + if ( status === 403 || status === 404 ) { + throw this.unavailable('resourceLoadError.stackUnavailable', 404); + } + throw err; + }); } return hash(dependencies, 'Load dependencies').then((results) => { + if ( results.stack && !resourceValue(results.stack, 'actionLinks.upgrade') ) { + throw this.unavailable('newCatalog.upgradeUnavailable'); + } + if ( !results.stack ) { results.stack = store.createRecord({ @@ -73,6 +103,7 @@ export default Route.extend(RequireCreatePermission, { let verArr = catalogVersionOptions(links, currentOption); return EmberObject.create({ + projectId, stack: results.stack, tpl: results.tpl, upgrade: results.upgrade, diff --git a/app/catalog-tab/launch/template.hbs b/app/catalog-tab/launch/template.hbs index 7edebdec32..0d0cb5ce8e 100644 --- a/app/catalog-tab/launch/template.hbs +++ b/app/catalog-tab/launch/template.hbs @@ -1,6 +1,7 @@ {{new-catalog allTemplates=this.model.allTemplates stackResource=this.model.stack + projectId=this.model.projectId templateResource=this.model.tpl versionLinks=this.model.versionLinks versionsArray=this.model.versionsArray diff --git a/app/components/edit-registry/component.js b/app/components/edit-registry/component.js index 1deed47d5b..358ff92116 100644 --- a/app/components/edit-registry/component.js +++ b/app/components/edit-registry/component.js @@ -15,10 +15,26 @@ export default ModalBase.extend(NewOrEdit, { editing: true, primaryResource: null, intl: service(), + projects: service(), missingCredential: false, credentialSaveAttempted: false, credentialOutcomeUnknown: false, + canSaveCredential: function() { + let projectId = this.get('originalModel.projectId'); + let currentProjectId = this.get('projects.current.id'); + if ( !currentProjectId || this.get('projects.schemaProjectId') !== currentProjectId || + projectId !== currentProjectId ) { + return false; + } + + let credential = this.get('originalModel.credential'); + return credential ? Boolean(credential.get('actionLinks.update')) : + this.get('projects').canCreateResource('registryCredential'); + }.property('originalModel.credential', 'originalModel.credential.actionLinks.update', + 'originalModel.projectId', 'projects.current.id', 'projects.schemaProjectId', + 'projects.schemaLoadGeneration'), + init: function() { this._super(...arguments); var orig = this.get('originalModel'); @@ -46,6 +62,10 @@ export default ModalBase.extend(NewOrEdit, { }, doSave: function() { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + if ( !this.get('missingCredential') ) { const credential = this.get('primaryResource'); const data = { @@ -53,11 +73,26 @@ export default ModalBase.extend(NewOrEdit, { secretValue: credential.get('secretValue'), }; - return this._super({data}); + const registryId = this.get('originalModel.registry.id'); + const saveCredential = this._super.bind(this); + return this.get('originalModel.registry.store').find('registrycredential', credential.get('id'), {forceReload: true}).then((fresh) => { + if ( !fresh || fresh.get('registryId') !== registryId ) { + throw {status: 404, code: 'NotFound', messageKey: 'resourceSaveError.unavailable'}; + } + if ( !this.get('canSaveCredential') || !fresh.get('actionLinks.update') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + + return saveCredential({data}); + }); } const registry = this.get('originalModel.registry'); return registry.get('store').find('registrycredential', null, {forceReload: true}).then((credentials) => { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + const existing = credentialsForRegistry(credentials, registry.get('id')); if ( existing.get('length') ) { throw new Error(this.get('intl').t('editRegistry.credentialAppeared')); @@ -66,8 +101,14 @@ export default ModalBase.extend(NewOrEdit, { throw new Error(this.get('intl').t('editRegistry.credentialOutcomeUnknown')); } - this.set('credentialSaveAttempted', true); - return resolve().then(() => this.get('primaryResource').save()).then( + return resolve().then(() => { + if ( !this.get('canSaveCredential') ) { + throw {status: 403, code: 'Forbidden', messageKey: 'resourceSaveError.unavailable'}; + } + + this.set('credentialSaveAttempted', true); + return this.get('primaryResource').save(); + }).then( (saved) => this.mergeResult(saved), (error) => { this.set('credentialOutcomeUnknown', !definitelyRejected(error)); diff --git a/app/components/edit-registry/template.hbs b/app/components/edit-registry/template.hbs index 077428a548..aaa53c115c 100644 --- a/app/components/edit-registry/template.hbs +++ b/app/components/edit-registry/template.hbs @@ -35,4 +35,4 @@ -{{save-cancel editing=this.editing save="save" cancel="cancel"}} +{{save-cancel editing=this.editing save="save" cancel="cancel" saveDisabled=(not this.canSaveCredential)}} diff --git a/app/components/info-multi-stats/component.js b/app/components/info-multi-stats/component.js index 6f4478de24..ecb40f188f 100644 --- a/app/components/info-multi-stats/component.js +++ b/app/components/info-multi-stats/component.js @@ -1,5 +1,5 @@ import { cancel, next } from '@ember/runloop'; -import { alias, and, not } from '@ember/object/computed'; +import { alias, and, not, or } from '@ember/object/computed'; import { service } from '@ember/service'; import Component from '@ember/component'; import bb from 'billboard.js'; @@ -93,7 +93,18 @@ export default Component.extend({ active: alias('statsSocket.active'), loading: alias('statsSocket.loading'), notRenderOk: not('renderOk'), - waitingForData: and('available', 'notRenderOk'), + statsError: alias('statsSocket.connectError'), + statsErrorStatus: alias('statsSocket.connectErrorStatus'), + noStatsError: not('statsError'), + noStatsAvailable: not('available'), + showUnavailable: or('noStatsAvailable', 'statsError'), + waitingForData: and('available', 'notRenderOk', 'noStatsError'), + unavailableMessage: function() { + const status = this.get('statsErrorStatus'); + const key = status === 401 ? 'authError' : status === 403 ? 'permissionError' : + status === 404 ? 'notFound' : 'utilizationStats'; + return this.get('intl').t(`infoMultiStats.${key}`); + }.property('statsErrorStatus'), cpuCanvas: '#cpuGraph', cpuGraph: null, diff --git a/app/components/info-multi-stats/template.hbs b/app/components/info-multi-stats/template.hbs index 7f8ffe3e49..6e5249aad2 100644 --- a/app/components/info-multi-stats/template.hbs +++ b/app/components/info-multi-stats/template.hbs @@ -16,8 +16,8 @@
{{t 'infoMultiStats.connecting'}}
-
- {{t 'infoMultiStats.utilizationStats'}} +
+ {{this.unavailableMessage}}
@@ -36,8 +36,8 @@
{{t 'infoMultiStats.connecting'}}
-
- {{t 'infoMultiStats.utilizationStats'}} +
+ {{this.unavailableMessage}}
@@ -57,8 +57,8 @@
{{t 'infoMultiStats.connecting'}}
-
- {{t 'infoMultiStats.utilizationStats'}} +
+ {{this.unavailableMessage}}
@@ -77,8 +77,8 @@
{{t 'infoMultiStats.connecting'}}
-
- {{t 'infoMultiStats.utilizationStats'}} +
+ {{this.unavailableMessage}}
diff --git a/app/components/lb-addtl-info/template.hbs b/app/components/lb-addtl-info/template.hbs index 9bac5ed402..a5bf84732a 100644 --- a/app/components/lb-addtl-info/template.hbs +++ b/app/components/lb-addtl-info/template.hbs @@ -46,14 +46,18 @@
- {{#if this.service.canScale}} - {{this.service.scale}} -
- - -
+ {{#if this.service.isGlobalScale}} + {{t 'lbAddtlInfo.global'}} {{else}} - {{if this.service.isGlobalScale (t 'lbAddtlInfo.global')}} + {{#if this.service.isReal}} + {{this.service.scale}} + {{#if this.service.canScale}} +
+ + +
+ {{/if}} + {{/if}} {{/if}}
diff --git a/app/components/new-catalog/component.js b/app/components/new-catalog/component.js index d17ecfbc05..458160ad7d 100644 --- a/app/components/new-catalog/component.js +++ b/app/components/new-catalog/component.js @@ -24,6 +24,7 @@ export default Component.extend(NewOrEdit, { allTemplates: null, templateResource: null, stackResource: null, + projectId: null, versionsArray: null, versionsLinks: null, actuallySave: true, @@ -44,6 +45,29 @@ export default Component.extend(NewOrEdit, { primaryResource: alias('stackResource'), templateBase: alias('templateResource.templateBase'), editing: notEmpty('stackResource.id'), + canSubmit: computed('actuallySave', 'editing', 'projectId', 'projects.current.id', + 'projects.schemaProjectId', 'projects.schemaLoadGeneration', + 'stackResource.actionLinks.upgrade', function() { + if ( !this.get('actuallySave') ) { + return true; + } + + let projectId = this.get('projectId'); + if ( !projectId || this.get('projects.current.id') !== projectId || + this.get('projects.schemaProjectId') !== projectId ) { + return false; + } + + return this.get('editing') ? !!this.get('stackResource.actionLinks.upgrade') : + this.get('projects').canCreateResource('stack'); + }), + submissionErrorKey: computed('editing', 'projectId', 'projects.current.id', function() { + if ( this.get('editing') ) { + return 'newCatalog.upgradeUnavailable'; + } + return this.get('projectId') === this.get('projects.current.id') ? + 'newCatalog.permissionDenied' : 'newCatalog.projectChanged'; + }), previewOpen: false, previewTab: null, @@ -310,13 +334,13 @@ export default Component.extend(NewOrEdit, { var errors = []; if (!this.get('editing') && !this.get('stackResource.name')) { - errors.push('Name is required'); + errors.push(this.get('intl').t('validation.required', {key: this.get('intl').t('generic.name')})); } if (this.get('selectedTemplateModel.questions')) { this.get('selectedTemplateModel.questions').forEach((item) => { if (item.required && isCatalogQuestionAnswerMissing(item.answer)) { - errors.push(`${item.label} is required`); + errors.push(this.get('intl').t('validation.required', {key: item.label})); } }); } @@ -370,6 +394,10 @@ export default Component.extend(NewOrEdit, { doSave() { var stack = this.get('stackResource'); + if ( !this.get('canSubmit') ) { + let messageKey = this.get('submissionErrorKey'); + throw {status: 403, code: 'Forbidden', messageKey, message: this.get('intl').t(messageKey)}; + } if (this.get('editing')) { return stack.doAction('upgrade', { dockerCompose: stack.get('dockerCompose'), @@ -383,7 +411,13 @@ export default Component.extend(NewOrEdit, { }, doneSaving() { - var projectId = this.get('projects.current.id'); + const projectId = this.get('projectId'); + // A save can finish after the user has selected another environment. + // Never navigate a completed stack ID under that newer environment. + if (!projectId || this.isDestroying || this.isDestroyed || + this.get('projects.current.id') !== projectId) { + return; + } if ( this.get('stackResource.system') ) { return this.get('router').transitionTo('stack', projectId, this.get('primaryResource.id'), {queryParams: {which: 'infra'}}); diff --git a/app/components/new-catalog/template.hbs b/app/components/new-catalog/template.hbs index 88ea4b290d..b3c00a6a75 100644 --- a/app/components/new-catalog/template.hbs +++ b/app/components/new-catalog/template.hbs @@ -173,7 +173,10 @@ {{/if}} {{else}} diff --git a/app/components/service-addtl-info-content/template.hbs b/app/components/service-addtl-info-content/template.hbs index e54cfa1103..b79c5ce4a7 100644 --- a/app/components/service-addtl-info-content/template.hbs +++ b/app/components/service-addtl-info-content/template.hbs @@ -51,14 +51,18 @@
- {{#if this.service.canScale}} - {{this.service.scale}} -
- - -
+ {{#if this.service.isGlobalScale}} + {{t 'serviceInfoPartial.global'}} {{else}} - {{if this.service.isGlobalScale (t 'serviceInfoPartial.global')}} + {{#if this.service.isReal}} + {{this.service.scale}} + {{#if this.service.canScale}} +
+ + +
+ {{/if}} + {{/if}} {{/if}}
diff --git a/app/components/stack-header/component.js b/app/components/stack-header/component.js index 19f0fede5c..bfedcebab9 100644 --- a/app/components/stack-header/component.js +++ b/app/components/stack-header/component.js @@ -8,8 +8,24 @@ export default Component.extend({ projects: service(), hasVm: alias('projects.current.virtualMachine'), - canCreateService: computed('projects.current.id', 'projects.schemaProjectId', function() { - return this.get('projects').canCreateResource('service'); + createOptions: computed('projects.current.id', 'projects.current.isWindows', 'projects.current.virtualMachine', 'projects.schemaProjectId', 'projects.schemaLoadGeneration', function() { + let projects = this.get('projects'); + let windows = this.get('projects.current.isWindows'); + let service = projects.canCreateResource('service'); + let balancer = !windows && projects.canCreateResource('loadBalancerService'); + let alias = projects.canCreateResource('dnsService'); + let external = projects.canCreateResource('externalService'); + let vm = service && !windows && this.get('hasVm'); + + return { + service, + balancer, + alias, + external, + vm, + other: balancer || alias || external || vm, + any: service || balancer || alias || external || vm, + }; }), actions: { diff --git a/app/components/stack-header/template.hbs b/app/components/stack-header/template.hbs index bd349073d2..e906633546 100644 --- a/app/components/stack-header/template.hbs +++ b/app/components/stack-header/template.hbs @@ -20,23 +20,32 @@
{{/if}} - {{#if this.canCreateService}} + {{#if this.createOptions.any}}
- {{t 'stackHeader.add.service'}} - - + {{/if}}
{{/if}} diff --git a/app/components/stack-section/component.js b/app/components/stack-section/component.js index b2f7cc4101..ecba81c503 100644 --- a/app/components/stack-section/component.js +++ b/app/components/stack-section/component.js @@ -14,8 +14,24 @@ export default Component.extend({ single : false, showAddService : true, - canCreateService: computed('projects.current.id', 'projects.schemaProjectId', function() { - return this.get('projects').canCreateResource('service'); + createOptions: computed('projects.current.id', 'projects.current.isWindows', 'projects.current.virtualMachine', 'projects.schemaProjectId', 'projects.schemaLoadGeneration', function() { + let projects = this.get('projects'); + let windows = this.get('projects.current.isWindows'); + let service = projects.canCreateResource('service'); + let balancer = !windows && projects.canCreateResource('loadBalancerService'); + let alias = projects.canCreateResource('dnsService'); + let external = projects.canCreateResource('externalService'); + let vm = service && !windows && this.get('hasVm'); + + return { + service, + balancer, + alias, + external, + vm, + other: balancer || alias || external || vm, + any: service || balancer || alias || external || vm, + }; }), collapsed : true, diff --git a/app/components/stack-section/template.hbs b/app/components/stack-section/template.hbs index 374bdc22cb..3dbaff68b6 100644 --- a/app/components/stack-section/template.hbs +++ b/app/components/stack-section/template.hbs @@ -50,25 +50,32 @@
{{upgrade-btn model=this.model}} - {{#if (and this.showAddService this.canCreateService)}} + {{#if (and this.showAddService this.createOptions.any)}}
- {{t 'stackSection.add.service'}} - + {{#if this.createOptions.service}} + {{t 'stackSection.add.service'}} + {{/if}} + {{#if this.createOptions.other}} - + {{/if}}
{{/if}}
diff --git a/app/components/upgrade-btn/component.js b/app/components/upgrade-btn/component.js index 93d272a5e3..6414a0f599 100644 --- a/app/components/upgrade-btn/component.js +++ b/app/components/upgrade-btn/component.js @@ -5,6 +5,10 @@ export default Component.extend(UpgradeComponent, { tagName : 'button', classNames : ['btn','btn-sm'], classNameBindings : ['color'], + attributeBindings : ['disabled'], + disabled : function() { + return !this.get('canApplyUpgrade'); + }.property('canApplyUpgrade'), click: function() { this.doUpgrade(); diff --git a/app/components/view-edit-project/component.js b/app/components/view-edit-project/component.js index c59997aa25..4ce2c49f4b 100644 --- a/app/components/view-edit-project/component.js +++ b/app/components/view-edit-project/component.js @@ -258,7 +258,7 @@ export default Component.extend(NewOrEdit, Sortable, { return {status, message: this.get('intl').t('login.error.timedOut')}; } if ( status === 403 || status === 404 ) { - return {status, message: this.get('intl').t(deniedKey)}; + return {status, message: this.get('intl').t(deniedKey), messageKey: deniedKey}; } if ( status >= 500 && status <= 599 ) { return {status, message: this.get('intl').t(failedKey)}; diff --git a/app/mixins/new-or-edit.js b/app/mixins/new-or-edit.js index 89e9ff932d..ebc77ddd74 100644 --- a/app/mixins/new-or-edit.js +++ b/app/mixins/new-or-edit.js @@ -1,10 +1,12 @@ import { resolve } from 'rsvp'; import { alias } from '@ember/object/computed'; +import { service } from '@ember/service'; import Mixin from '@ember/object/mixin'; import Resource from 'ember-api-store/models/resource'; import Errors from 'ui/utils/errors'; export default Mixin.create({ + intl: service(), originalModel: null, errors: null, saving: false, @@ -41,7 +43,7 @@ export default Mixin.create({ error: function(err) { if (err) { - var body = Errors.stringify(err); + var body = Errors.stringify(err, this.get('intl')); this.set('errors', [body]); } else diff --git a/app/mixins/upgrade-component.js b/app/mixins/upgrade-component.js index 049e9ae120..8e81556a27 100644 --- a/app/mixins/upgrade-component.js +++ b/app/mixins/upgrade-component.js @@ -122,10 +122,14 @@ export default Mixin.create({ this.updateStatus(); }, - color: computed('upgradeStatus', function() { + color: computed('upgradeStatus', 'canApplyUpgrade', function() { + if ( this.get('upgradeStatus') === NONE ) { + return 'hide'; + } + if ( !this.get('canApplyUpgrade') ) { + return 'btn-disabled'; + } switch ( this.get('upgradeStatus') ) { - case NONE: - return 'hide'; case CURRENT: return 'btn-info'; case LOADING: @@ -141,6 +145,15 @@ export default Mixin.create({ } }), + canApplyUpgrade: computed('upgradeStatus', 'model.actionLinks.{upgrade,finishupgrade}', function() { + let status = this.get('upgradeStatus'); + if ( status === UPGRADED ) { + return !!this.get('model.actionLinks.finishupgrade'); + } + return [REQUIRED, AVAILABLE, CURRENT].indexOf(status) >= 0 && + !!this.get('model.actionLinks.upgrade'); + }), + currentVersion: computed('upgradeInfo','model.externalId', function() { let text = this.get('intl').findTranslationByKey('upgradeBtn.version.current'); let version = this.get('upgradeInfo.version'); @@ -152,6 +165,9 @@ export default Mixin.create({ }), doUpgrade() { + if ( !this.get('canApplyUpgrade') ) { + return; + } let status = this.get('upgradeStatus'); if ( [REQUIRED,AVAILABLE,CURRENT].indexOf(status) >= 0 ) diff --git a/app/models/container.js b/app/models/container.js index 8621f9dfe0..8080a60a46 100644 --- a/app/models/container.js +++ b/app/models/container.js @@ -178,8 +178,9 @@ var Container = Instance.extend({ }.property('primaryIpAddress','primaryAssociatedIpAddress'), canDelete: function() { - return ['removed','removing','purging','purged'].indexOf(this.get('state')) === -1; - }.property('state'), + return !!this.get('actionLinks.remove') && + ['removed','removing','purging','purged'].indexOf(this.get('state')) === -1; + }.property('state', 'actionLinks.remove'), isManaged: notEmpty('systemContainer'), diff --git a/app/models/registry.js b/app/models/registry.js index 1d7992759d..f6cd777d4c 100644 --- a/app/models/registry.js +++ b/app/models/registry.js @@ -7,6 +7,7 @@ var Registry = Resource.extend({ type: 'registry', serverAddress: null, modalService: service('modal'), + projects: service(), actions: { deactivate: function() { @@ -18,16 +19,39 @@ var Registry = Resource.extend({ }, edit: function() { - this.get('store').find('registry').then((registries) => { + if ( !this.get('canEditCredential') ) { + return; + } + + let projectId = this.get('projects.current.id'); + return this.get('store').find('registry').then((registries) => { + if ( projectId !== this.get('projects.current.id') || !this.get('canEditCredential') ) { + return; + } + this.get('modalService').toggleModal('edit-registry', EmberObject.create({ registries: registries, registry: this, credential: this.get('credential'), + projectId, })); }); }, }, + canEditCredential: function() { + let projectId = this.get('projects.current.id'); + if ( !projectId || this.get('projects.schemaProjectId') !== projectId ) { + return false; + } + + let credential = this.get('credential'); + + return credential ? Boolean(credential.get('actionLinks.update')) : + this.get('projects').canCreateResource('registryCredential'); + }.property('credential', 'credential.actionLinks.update', + 'projects.current.id', 'projects.schemaProjectId', 'projects.schemaLoadGeneration'), + availableActions: function() { var a = this.get('actionLinks'); @@ -40,9 +64,9 @@ var Registry = Resource.extend({ { label: 'action.restore', icon: 'icon icon-medicalcross', action: 'restore', enabled: !!a.restore }, { label: 'action.viewInApi', icon: 'icon icon-external-link',action: 'goToApi', enabled: true }, { divider: true }, - { label: 'action.edit', icon: 'icon icon-edit', action: 'edit', enabled: !!a.update }, + { label: 'action.edit', icon: 'icon icon-edit', action: 'edit', enabled: this.get('canEditCredential') }, ]; - }.property('actionLinks.{update,activate,deactivate,restore,remove,purge}'), + }.property('actionLinks.{activate,deactivate,restore,remove,purge}', 'canEditCredential'), displayName: alias('displayAddress'), displayAddress: function() { diff --git a/app/models/service.js b/app/models/service.js index c1e632c6de..99b5964f01 100644 --- a/app/models/service.js +++ b/app/models/service.js @@ -1,5 +1,6 @@ import EmberObject, { computed } from '@ember/object'; import { cancel, later } from '@ember/runloop'; +import { resolve } from 'rsvp'; import { alias } from '@ember/object/computed'; import { service } from '@ember/service'; import Resource from 'ember-api-store/models/resource'; @@ -11,6 +12,7 @@ var Service = Resource.extend({ type: 'service', intl: service(), growl: service(), + projects: service(), modalService: service('modal'), instances: denormalizeIdArray('instanceIds'), @@ -71,13 +73,24 @@ var Service = Resource.extend({ }, scaleUp() { + if ( !this.get('canScale') ) { + return; + } + if ( this.get('scaleTimer') === null ) { + this.set('scaleBeforePending', this.get('scale')); + this.set('scaleRequestProjectId', this.get('projectId')); + } this.incrementProperty('scale'); this.saveScale(); }, scaleDown() { - if ( this.get('scale') >= 1 ) + if ( this.get('canScale') && this.get('scale') > 1 ) { + if ( this.get('scaleTimer') === null ) { + this.set('scaleBeforePending', this.get('scale')); + this.set('scaleRequestProjectId', this.get('projectId')); + } this.decrementProperty('scale'); this.saveScale(); } @@ -127,6 +140,9 @@ var Service = Resource.extend({ }, scaleTimer: null, + scaleBeforePending: null, + scaleRequestProjectId: null, + scaleSaving: false, saveScale() { if ( this.get('scaleTimer') !== null ) { @@ -134,14 +150,43 @@ var Service = Resource.extend({ } var timer = later(this, function() { - this.save({data: {scale: this.get('scale')}}).catch((err) => { - this.get('growl').fromError('Error updating scale',err); - }); + this.set('scaleTimer', null); + this.persistScale(); }, 500); this.set('scaleTimer', timer); }, + persistScale() { + let title = this.get('intl').t('resourceSaveError.scaleFailed'); + let previous = this.get('scaleBeforePending'); + if ( !this.get('canScale') || + (this.get('scaleRequestProjectId') && + this.get('projectId') !== this.get('scaleRequestProjectId')) ) { + if ( previous !== null && previous !== undefined ) { + this.set('scale', previous); + } + this.set('scaleBeforePending', null); + this.set('scaleRequestProjectId', null); + this.get('growl').fromError(title, {status: 403}); + return resolve(false); + } + this.set('scaleSaving', true); + return resolve().then(() => this.save({data: {scale: this.get('scale')}})).then(() => { + return true; + }, (err) => { + if ( previous !== null && previous !== undefined ) { + this.set('scale', previous); + } + this.get('growl').fromError(title, err); + return false; + }).finally(() => { + this.set('scaleBeforePending', null); + this.set('scaleRequestProjectId', null); + this.set('scaleSaving', false); + }); + }, + availableActions: function() { var a = this.get('actionLinks'); @@ -241,15 +286,13 @@ var Service = Resource.extend({ }.property('launchConfig.labels'), canScale: function() { - if ( this.get('isReal') ) - { - return !this.get('isGlobalScale'); - } - else - { - return false; - } - }.property('isReal','isGlobalScale'), + const projectId = this.get('projectId'); + return !!projectId && this.get('projects.current.id') === projectId && + this.get('projects.schemaProjectId') === projectId && + this.get('isReal') && !this.get('isGlobalScale') && !this.get('scaleSaving') && + !!this.get('actionLinks.update'); + }.property('projectId', 'projects.current.id', 'projects.schemaProjectId', + 'isReal','isGlobalScale','scaleSaving','actionLinks.update'), canHaveContainers: function() { if ( this.get('isReal') ) { diff --git a/app/service/template.hbs b/app/service/template.hbs index 829a450b20..d0dc598154 100644 --- a/app/service/template.hbs +++ b/app/service/template.hbs @@ -52,17 +52,22 @@ {{/if}}
- {{#if this.service.canScale}} - {{this.service.scale}} -   - -   -
- - -
+ {{#if this.service.isGlobalScale}} + {{t 'servicePage.multistat.global'}} {{else}} - {{if this.service.isGlobalScale (t 'servicePage.multistat.global') (t 'generic.na')}} + {{#if this.service.isReal}} + {{this.service.scale}} + {{#if this.service.canScale}} +   +   +
+ + +
+ {{/if}} + {{else}} + {{t 'generic.na'}} + {{/if}} {{/if}}
{{#if this.service.hasImage}} diff --git a/app/services/growl.js b/app/services/growl.js index 491f5622a4..043a705253 100644 --- a/app/services/growl.js +++ b/app/services/growl.js @@ -1,9 +1,12 @@ import Service from '@ember/service'; +import { service } from '@ember/service'; import Errors from 'ui/utils/errors'; import Util from 'ui/utils/util'; export default Service.extend({ + intl: service(), init: function() { + this._super(...arguments); $.jGrowl.defaults.pool = 6; $.jGrowl.defaults.closeTemplate = ''; $.jGrowl.defaults.closerTemplate = '
'; @@ -40,7 +43,12 @@ export default Service.extend({ }, fromError: function(title, err) { - var body = Errors.stringify(err); + var status = Errors.status(err); + // Growls also report deletes and resource actions, not just saves. + // Keep denied/missing-resource details private without calling them saves. + var body = status === 403 || status === 404 ? + this.get('intl').t('resourceSaveError.actionUnavailable') : + Errors.stringify(err, this.get('intl')); this.error(title,body); }, }); diff --git a/app/stacks/index/template.hbs b/app/stacks/index/template.hbs index b5a6016fc5..e75f0da4e3 100644 --- a/app/stacks/index/template.hbs +++ b/app/stacks/index/template.hbs @@ -26,7 +26,7 @@
{{#each this.arranged as |stack|}} - {{stack-section model=stack showAddtlInfo='showAddtlInfo' showAddService=(and this.canCreateService (not stack.system))}} + {{stack-section model=stack showAddtlInfo='showAddtlInfo' showAddService=(not stack.system)}} {{else}} {{#if (and this.model.stacks.length this.tags)}}
{{t 'stacksPage.noMatch'}}
diff --git a/app/utils/errors.js b/app/utils/errors.js index 2335cebd87..9ba7c663ff 100644 --- a/app/utils/errors.js +++ b/app/utils/errors.js @@ -67,6 +67,53 @@ function nestedMessage(value, seen, depth) { nonEmptyString(value.type); } +function fieldValue(value, key) { + if ( !value || typeof value !== 'object' ) { + return null; + } + + let direct = value[key]; + return direct === undefined && typeof value.get === 'function' ? value.get(key) : direct; +} + +function nestedStringField(value, key, seen, depth) { + if ( depth > 4 || value === null || value === undefined ) { + return null; + } + if ( typeof value === 'string' ) { + let parsed = parsedJSON(value); + return parsed ? nestedStringField(parsed, key, seen, depth + 1) : null; + } + if ( typeof value !== 'object' || seen.indexOf(value) >= 0 ) { + return null; + } + seen.push(value); + + let direct = nonEmptyString(fieldValue(value, key)); + if ( direct ) { + return direct; + } + + let response = fieldValue(value, 'response'); + let xhr = fieldValue(value, 'xhr'); + for ( let item of [ + fieldValue(value, 'body'), + fieldValue(value, 'responseJSON'), + response && fieldValue(response, 'data'), + response && fieldValue(response, 'body'), + xhr && fieldValue(xhr, 'responseJSON'), + xhr && fieldValue(xhr, 'responseText'), + response, + xhr, + ] ) { + let result = nestedStringField(item, key, seen, depth + 1); + if ( result ) { + return result; + } + } + return null; +} + function nestedStatus(value, seen, depth) { if ( depth > 4 || value === null || value === undefined ) { return null; @@ -82,7 +129,7 @@ function nestedStatus(value, seen, depth) { } seen.push(value); - for ( let candidate of [value.status, value.statusCode] ) { + for ( let candidate of [fieldValue(value, 'status'), fieldValue(value, 'statusCode')] ) { let status = Number(candidate); if ( Number.isInteger(status) && status >= 100 && status <= 599 ) { return status; @@ -100,7 +147,28 @@ function nestedStatus(value, seen, depth) { } export default { - stringify(err) { + stringify(err, intl) { + if ( intl && typeof intl.t === 'function' ) { + let status = nestedStatus(err, [], 0); + + if ( status === 403 || status === 404 ) { + // A denied resource and a missing resource must have the same visible + // explanation. Client-created errors can supply a more specific key. + let key = nonEmptyString(fieldValue(err, 'messageKey')) || 'resourceSaveError.unavailable'; + return intl.t(key); + } + + if ( status === 422 ) { + let intro = intl.t('resourceSaveError.validation'); + let field = nestedStringField(err, 'fieldName', [], 0); + let detail = nestedStringField(err, 'detail', [], 0); + let explanation = detail || nestedStringField(err, 'message', [], 0) || + nestedStringField(err, 'code', [], 0); + let context = [field, explanation].filter(Boolean).join(': '); + return context ? `${intro} ${context}` : intro; + } + } + var str; if ( typeof err === 'string' ) { @@ -208,7 +276,8 @@ export default { str = err; } - return nonEmptyString(str) || nestedMessage(err, [], 0); + return nonEmptyString(str) || nestedMessage(err, [], 0) || + (intl && typeof intl.t === 'function' ? intl.t('resourceSaveError.failed') : null); }, status(err) { diff --git a/app/utils/multi-stats.js b/app/utils/multi-stats.js index 38edffb339..41ba197273 100644 --- a/app/utils/multi-stats.js +++ b/app/utils/multi-stats.js @@ -1,6 +1,8 @@ import { and } from '@ember/object/computed'; +import { cancel, later } from '@ember/runloop'; import Evented from '@ember/object/evented'; import EmberObject from '@ember/object'; +import { resolve } from 'rsvp'; import Socket from "ui/utils/socket"; import C from 'ui/utils/constants'; @@ -23,6 +25,10 @@ export default EmberObject.extend(Evented, { connected: false, prev: null, closed: false, + connectError: false, + connectErrorStatus: null, + connectPending: null, + retryTimer: null, init() { this._super(); @@ -30,14 +36,18 @@ export default EmberObject.extend(Evented, { }, available: function() { - return C.ACTIVEISH_STATES.indexOf(this.get('resource.state')) >= 0 && this.get('resource.healthState') !== 'started-once'; - }.property('resource.{state,healthState}'), + const host = this.get('resource.primaryHost'); + const hostDisconnected = host && (host.get('state') === 'disconnected' || + host.get('agentState') === 'disconnected'); + return C.ACTIVEISH_STATES.indexOf(this.get('resource.state')) >= 0 && + this.get('resource.healthState') !== 'started-once' && !hostDisconnected; + }.property('resource.{state,healthState}', 'resource.primaryHost.{state,agentState}'), active: and('available', 'connected'), loading: function() { - return this.get('available') && !this.get('connected'); - }.property('available','connected'), + return this.get('available') && !this.get('connected') && !this.get('connectError'); + }.property('available','connected','connectError'), onAvailableChanged: function() { if ( this.get('available') ) @@ -51,15 +61,25 @@ export default EmberObject.extend(Evented, { }.observes('available'), connect() { - if ( this.get('socket') || this.get('closed') ) + if ( this.get('socket') || this.get('closed') || !this.get('available') ) { return; } + if (this.get('connectPending')) { + return this.get('connectPending'); + } + + cancel(this.get('retryTimer')); + this.setProperties({retryTimer: null, connectError: false, connectErrorStatus: null}); + this.set('prev', {}); if ( this.get('resource').hasLink(this.get('linkName')) ) { - this.get('resource').followLink(this.get('linkName')).then((response) => { + const pending = resolve().then(() => this.get('resource').followLink(this.get('linkName'))).then((response) => { + if (this.get('closed') || !this.get('available')) { + return; + } if (response.get('url') && response.get('token')) { var url = response.get('url') + '?token=' + encodeURIComponent(response.get('token')); @@ -77,6 +97,10 @@ export default EmberObject.extend(Evented, { }); socket.on('connected', (/*tries, after*/) => { + if (this.get('closed') || !this.get('available')) { + socket.disconnect(); + return; + } this.set('connected',true); this.trigger('connected'); }); @@ -88,12 +112,56 @@ export default EmberObject.extend(Evented, { this.set('socket', socket); socket.connect(); + } else { + this.statsUnavailable({status: 404}, false); + } + }).catch((error) => { + // An offline host returns 503 here. It is an unavailable stats + // stream, not an unhandled route failure or a reason to clear login. + // Also handle a synchronous failure while constructing the socket. + if (!this.get('closed')) { + const socket = this.get('socket'); + if (socket) { + this.set('socket', null); + try { + socket.disconnect(); + } catch (_cleanupError) { + // Keep the original connection failure; retry owns a fresh socket. + } + } + const status = Number(error && (error.status || (error.xhr && error.xhr.status))); + this.statsUnavailable(error, !status || status >= 500); + } + }); + this.set('connectPending', pending); + return pending.finally(() => { + if (this.get('connectPending') === pending) { + this.set('connectPending', null); } }); + } else { + this.statsUnavailable({status: 404}, false); + } + }, + + statsUnavailable(error, retry = true) { + const status = error && (error.status || (error.xhr && error.xhr.status)); + this.setProperties({connected: false, connectError: true, connectErrorStatus: status || null}); + if (!retry) { + cancel(this.get('retryTimer')); + this.set('retryTimer', null); + } + if (retry && !this.get('closed') && this.get('available') && this.get('retryTimer') === null) { + this.set('retryTimer', later(this, function() { + this.set('retryTimer', null); + this.connect(); + }, 30000)); } }, disconnect() { + cancel(this.get('retryTimer')); + this.set('retryTimer', null); this.set('connected',false); var socket = this.get('socket'); diff --git a/config/translation-fallback-prefixes.js b/config/translation-fallback-prefixes.js index 6eedffb4ca..ad39751158 100644 --- a/config/translation-fallback-prefixes.js +++ b/config/translation-fallback-prefixes.js @@ -15,6 +15,12 @@ module.exports = Object.freeze([ // Permission and account-validation errors must never render a missing-key // marker. Keep reviewed English copy as the fallback outside zh-tw. 'resourceLoadError.', + 'resourceSaveError.', + 'infoMultiStats.', + 'newCatalog.permissionDenied', + 'newCatalog.projectChanged', + 'newCatalog.templateUnavailable', + 'newCatalog.upgradeUnavailable', 'certificatesPage.permissionDenied', 'certificatesPage.new.refreshFailed', 'registriesPage.permissionDenied', diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 3e62ae434b..1e1851abcf 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.152", + "version": "1.6.153", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.152", + "version": "1.6.153", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -12338,9 +12338,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -15358,9 +15358,9 @@ } }, "node_modules/morgan": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", - "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/docs/releases/web-console-1.6.153.md b/docs/releases/web-console-1.6.153.md new file mode 100644 index 0000000000..5ecf6d836e --- /dev/null +++ b/docs/releases/web-console-1.6.153.md @@ -0,0 +1,32 @@ +# Web Console 1.6.153 + +This patch addresses gaps found while exercising the isolated six-role +permission matrix on Server v1.6.486. The UI now checks the selected project, +loaded schema and resource action link at the point of a Stack, Service, +Container, Catalog, or related write. The Server remains the authority for +authorization; a hidden or disabled control is not an API permission grant. + +Delayed actions are bound to their originating project. A queued project +upgrade cannot be sent to a newly selected project; a debounced Service scale +cannot write after the user switches projects; and a completed Catalog Stack +save cannot navigate a stale Stack ID into that newer project. Denied or +missing resources show a neutral localized error without exposing a private +ID. Registry edit recovery and Japanese labels were checked against their +actual forms. Host/container charts stop their loading spinner when their +stats link is unavailable; 401/403/404 are not retried, while transient 5xx +can retry using a fresh socket. No backend API contract or stored data changed. + +Focused Chrome QUnit tests cover role/capability changes, the three project +switch races, Registry recovery, translated messages and stats-link failures. +The separate Server v1.6.486 QA evidence includes six-role read-only feature +discovery and selected real write flows, but does not prove every button +submission or every direct resource-ID operation. VM, Secret, Certificate and +Receiver have no valid QA IDs in the current three-project inventory, so +their direct-ID authorization remains untested rather than marked passing. +Packaged Server browser acceptance must be recorded after this Web Console +asset is incorporated into a new immutable Server image. + +The Node 24 package lock changes its two root version fields from 1.6.152 to +1.6.153 and updates the development-only `fast-uri` override from 3.1.6 to +3.1.7 and `morgan` from 1.12.0 to 1.12.1, matching the reviewed lockfile +baseline. The live dependency audit remains enabled. diff --git a/package-lock.json b/package-lock.json index 3e62ae434b..1e1851abcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.152", + "version": "1.6.153", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.152", + "version": "1.6.153", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -12338,9 +12338,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -15358,9 +15358,9 @@ } }, "node_modules/morgan": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", - "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ceefd3505d..b372e8b49f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.152", + "version": "1.6.153", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { @@ -30,10 +30,10 @@ "lodash": "$lodash" }, "ember-cli-htmlbars": "7.0.1", - "fast-uri": "3.1.6", + "fast-uri": "3.1.7", "@xmldom/xmldom": "0.9.12", "js-yaml": "4.3.2", - "morgan": "1.12.0", + "morgan": "1.12.1", "nanoid": "3.3.18", "qs": "6.16.0", "raw-body": "2.5.3", diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index aec7616718..49e2b63402 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.152" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.152" +if [[ "$version" != "1.6.153" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.153" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index b25aed1387..4ce16fa820 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,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.152 browser-session broker-broadcast + 1.6.153 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index d0d29a8b26..d6972f7555 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.152": +if package.get("version") != "1.6.153": 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')}") diff --git a/scripts/check-ui-localization-quality b/scripts/check-ui-localization-quality index 878d5b6928..57ee0f286c 100755 --- a/scripts/check-ui-localization-quality +++ b/scripts/check-ui-localization-quality @@ -19,6 +19,38 @@ const requiredLocales = [ 'pt-br', 'ru-ru', 'uk-ua', 'zh-hans', 'zh-tw' ]; +// These Japanese sign-in and write-error messages are reviewed locally. Other +// locales, and unrelated Japanese authentication keys, retain English fallback. +const requiredJapanesePrefixes = [ + 'resourceLoadError.', + 'resourceSaveError.', + 'loginPage.localRecovery.', + 'loginPage.mfa.', + 'authPage.mfa.', + 'accountsPage.new.error.', + 'editAccount.error.' +]; +const requiredJapaneseKeys = new Set([ + 'newCatalog.permissionDenied', + 'newCatalog.projectChanged', + 'newCatalog.templateUnavailable', + 'newCatalog.upgradeUnavailable', + 'projectUpgrade.notAvailable', + 'viewEditProject.error.memberAlreadyListed', + 'viewEditProject.error.ownerRequired' +]); +const japaneseLiteralPlaceholders = new Set([ + 'loginPage.mfa.totp.placeholder', + 'authPage.mfa.email.placeholder', + 'authPage.mfa.settings.rpIdPlaceholder', + 'authPage.mfa.settings.originPlaceholder', + 'authPage.mfa.settings.trustedAuthenticationMethodsPlaceholder' +]); +function requiresJapaneseOwnLocale(key) { + return requiredJapaneseKeys.has(key) || + requiredJapanesePrefixes.some((prefix) => key.startsWith(prefix)); +} + const requiredValues = { 'de-de': { languageName: 'Deutsch (Deutschland)', @@ -517,6 +549,7 @@ requiredLocales.forEach((locale) => { const localizedValue = leaves[key]; if (!(key in leaves)) { const inheritsEnglish = locale !== 'zh-tw' && + !(locale === 'ja-jp' && requiresJapaneseOwnLocale(key)) && inheritedTranslationPrefixes.some((prefix) => key.startsWith(prefix)); if (inheritsEnglish) { inheritedEnglishFallbacks += 1; @@ -530,6 +563,15 @@ requiredLocales.forEach((locale) => { failures.push(`empty_value=${locale}:${key}`); return; } + if (locale === 'ja-jp' && requiresJapaneseOwnLocale(key)) { + if (japaneseLiteralPlaceholders.has(key)) { + if (localizedValue !== englishValue) { + failures.push(`changed_japanese_literal_placeholder=${key}`); + } + } else if (!/[ぁ-んァ-ヶ一-龠々]/u.test(localizedValue)) { + failures.push(`missing_japanese_text=${key}`); + } + } try { const expectedArguments = messageArguments(englishValue); const actualArguments = messageArguments(localizedValue); @@ -539,7 +581,9 @@ requiredLocales.forEach((locale) => { } catch (error) { failures.push(`invalid_icu=${locale}:${key}`); } - if (localizedValue.trim() === englishValue.trim() && !allowedExactEnglish(localizedValue, key, locale)) { + if (localizedValue.trim() === englishValue.trim() && + !(locale === 'ja-jp' && japaneseLiteralPlaceholders.has(key)) && + !allowedExactEnglish(localizedValue, key, locale)) { failures.push(`untranslated_value=${locale}:${key}`); } if (localeScriptPatterns[locale] && localeScriptPatterns[locale].test(localizedValue) && diff --git a/tests/unit/catalog-tab/launch/route-permissions-test.js b/tests/unit/catalog-tab/launch/route-permissions-test.js new file mode 100644 index 0000000000..a36a1be254 --- /dev/null +++ b/tests/unit/catalog-tab/launch/route-permissions-test.js @@ -0,0 +1,125 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { resolve, reject } from 'rsvp'; +import { module, test } from 'qunit'; +import LaunchRoute from 'ui/catalog-tab/launch/route'; + +module('Unit | Route | catalog launch permissions'); + +function fixture({canCreate=false, actionLinks={}, findError=null, templateError=null, upgradeError=null}={}) { + let reads = 0; + let stack = EmberObject.create({id: '1st-qa', actionLinks}); + let route = LaunchRoute.create({ + intl: {t: (key) => key}, + projects: {current: EmberObject.create({id: 'project-1'}), canCreateResource(type) { + if ( type !== 'stack' ) { + throw new Error('unexpected create type'); + } + return canCreate; + }}, + store: { + find(type, id) { + if ( type !== 'stack' || id !== '1st-qa' ) { + throw new Error('unexpected stack lookup'); + } + reads++; + return findError ? reject(findError) : resolve(stack); + }, + createRecord() { + return EmberObject.create({type: 'stack'}); + }, + }, + catalog: { + fetchTemplate(id, isUpgrade) { + let error = isUpgrade ? upgradeError : templateError; + if ( error ) { + return reject(error); + } + return resolve(EmberObject.create({ + defaultName: 'test', templateBase: 'user', versionLinks: {}, + upgradeVersionLinks: {}, version: '1', links: {self: '/version'}, + })); + }, + }, + modelFor() { + return EmberObject.create({catalog: [], templateBase: 'user'}); + }, + }); + + return {route, stack, reads: () => reads}; +} + +test('a version-id upgrade uses the exact stack upgrade action, not stack create', async function(assert) { + let data = fixture({actionLinks: {upgrade: '/upgrade'}}); + let result = await data.route.model({template: 'tpl', stackId: '1st-qa', upgrade: 'version-id'}); + assert.strictEqual(result.get('stack'), data.stack); + assert.strictEqual(result.get('projectId'), 'project-1'); + assert.strictEqual(data.reads(), 1); + run(() => data.route.destroy()); +}); + +test('direct template and version denials hide private API details', async function(assert) { + for (let status of [403, 404]) { + let create = fixture({canCreate: true, templateError: {status, message: 'private template ID'}}); + await create.route.model({template: 'tpl'}).then( + () => assert.ok(false, 'denied template must not open'), + (error) => { + assert.strictEqual(error.status, 404); + assert.strictEqual(error.messageKey, 'newCatalog.templateUnavailable'); + assert.notOk(JSON.stringify(error).includes('private template ID')); + } + ); + run(() => create.route.destroy()); + + let upgrade = fixture({ + actionLinks: {upgrade: '/upgrade'}, + upgradeError: {status, message: 'private version ID'}, + }); + await upgrade.route.model({template: 'tpl', stackId: '1st-qa', upgrade: 'version-id'}).then( + () => assert.ok(false, 'denied version must not open'), + (error) => { + assert.strictEqual(error.status, 404); + assert.strictEqual(error.messageKey, 'newCatalog.upgradeUnavailable'); + assert.notOk(JSON.stringify(error).includes('private version ID')); + } + ); + run(() => upgrade.route.destroy()); + } +}); + +test('create-only role cannot open an existing stack upgrade', async function(assert) { + let data = fixture({canCreate: true}); + await data.route.model({template: 'tpl', stackId: '1st-qa', upgrade: 'version-id'}).then( + () => assert.ok(false, 'upgrade form must be denied'), + (error) => assert.strictEqual(error.messageKey, 'newCatalog.upgradeUnavailable') + ); + assert.strictEqual(data.reads(), 1); + run(() => data.route.destroy()); +}); + +test('new stack requires create permission before loading the catalog', async function(assert) { + let denied = fixture(); + assert.throws(() => denied.route.model({template: 'tpl'}), + (error) => error.messageKey === 'newCatalog.permissionDenied'); + run(() => denied.route.destroy()); + + let allowed = fixture({canCreate: true}); + let result = await allowed.route.model({template: 'tpl'}); + assert.strictEqual(result.get('stack.type'), 'stack'); + run(() => allowed.route.destroy()); +}); + +test('direct stack ID 403 and 404 receive the same safe, localized error', async function(assert) { + for ( let status of [403, 404] ) { + let data = fixture({findError: {status, message: 'private detail'}}); + await data.route.model({template: 'tpl', stackId: '1st-qa', upgrade: 'version-id'}).then( + () => assert.ok(false, 'cross-project stack must not open'), + (error) => { + assert.strictEqual(error.status, 404); + assert.strictEqual(error.code, 'NotFound'); + assert.strictEqual(error.messageKey, 'resourceLoadError.stackUnavailable'); + } + ); + run(() => data.route.destroy()); + } +}); diff --git a/tests/unit/components/async-save-lifecycle-test.js b/tests/unit/components/async-save-lifecycle-test.js index 9d8129e392..1c8c4f86ac 100644 --- a/tests/unit/components/async-save-lifecycle-test.js +++ b/tests/unit/components/async-save-lifecycle-test.js @@ -164,7 +164,8 @@ test('environment project save distinguishes expired, denied, server, and valida assert.strictEqual(outcome.saved, false, `HTTP ${status} reports failure`); let expected = status === 401 ? 'login.error.timedOut' : status === 403 || status === 404 ? 'viewEditProject.error.projectNotSaved' : - status === 500 ? 'viewEditProject.error.projectFailed' : 'Original server error'; + status === 500 ? 'viewEditProject.error.projectFailed' : + 'resourceSaveError.validation Original server error'; assert.deepEqual(component.get('errors'), [expected], `HTTP ${status} shows the correct message`); assert.strictEqual(component.get('saving'), false, `HTTP ${status} releases the lock`); destroyOwned(component); diff --git a/tests/unit/components/edit-apikey-test.js b/tests/unit/components/edit-apikey-test.js index 10b79b72f3..397356fa27 100644 --- a/tests/unit/components/edit-apikey-test.js +++ b/tests/unit/components/edit-apikey-test.js @@ -26,6 +26,7 @@ test('cancel clears delayed focus; a stale callback has no input and cannot writ let originalClearTimeout = window.clearTimeout; let component = createOwned(EditApiKey, { renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), modalService: EmberObject.create({ modalOpts: null, toggleModal() { closed++; inputPresent = false; }, @@ -115,7 +116,7 @@ module('Integration | Component | edit apikey rapid cancel', function(hooks) { } }); - test('a denied Save shows its error without exposing key values', async function(assert) { + test('a denied Save shows a safe error without exposing key values', async function(assert) { let writes = 0; let newKey = () => EmberObject.create({ name: 'Disposable test key', @@ -143,7 +144,8 @@ module('Integration | Component | edit apikey rapid cancel', function(hooks) { assert.strictEqual(writes, 1, 'one save attempt reached the resource'); assert.strictEqual(find('.top-errors li').textContent.trim(), - 'You do not have permission to create API keys.', 'the API error is visible'); + this.owner.lookup('service:intl').t('resourceSaveError.unavailable'), + 'the localized denial is visible without exposing the API response'); assert.notOk(this.testRoot.textContent.includes('PUBLIC-NEVER-RENDER'), 'the public key is not in the failed form'); assert.notOk(this.testRoot.textContent.includes('SECRET-NEVER-RENDER'), 'the secret key is not in the failed form'); }); diff --git a/tests/unit/components/edit-container-test.js b/tests/unit/components/edit-container-test.js index da6208df56..90dd73fdde 100644 --- a/tests/unit/components/edit-container-test.js +++ b/tests/unit/components/edit-container-test.js @@ -24,6 +24,7 @@ function makeComponent({primarySave, portSave, linkSave}, closed) { }); let component = createOwned(EditContainer, { renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), modalService: EmberObject.create({ modalOpts: null, modalVisible: true, diff --git a/tests/unit/components/edit-registry-recovery-test.js b/tests/unit/components/edit-registry-recovery-test.js index d2d1d7fda7..f7fe094f1e 100644 --- a/tests/unit/components/edit-registry-recovery-test.js +++ b/tests/unit/components/edit-registry-recovery-test.js @@ -1,6 +1,6 @@ import { A } from '@ember/array'; import EmberObject from '@ember/object'; -import { resolve } from 'rsvp'; +import { defer, resolve } from 'rsvp'; import { module, test } from 'qunit'; import EditRegistry from 'ui/components/edit-registry/component'; @@ -15,6 +15,16 @@ test('a registry without credentials opens safely and checks the server before a let saveOptions; let reads = 0; let credentials = A([]); + let pendingRead = null; + let canCreate = true; + const projects = EmberObject.create({ + current: EmberObject.create({id: 'project-1'}), + schemaProjectId: 'project-1', + canCreateResource(type) { + assert.strictEqual(type, 'registryCredential'); + return canCreate; + }, + }); const store = EmberObject.create({ createRecord(data) { creates++; @@ -30,7 +40,7 @@ test('a registry without credentials opens safely and checks the server before a assert.strictEqual(type, 'registrycredential'); assert.strictEqual(id, null); assert.true(options.forceReload, 'the orphan path bypasses a cached collection'); - return resolve(credentials); + return pendingRead ? pendingRead.promise : resolve(credentials); }, }); const registry = EmberObject.create({ @@ -42,7 +52,8 @@ test('a registry without credentials opens safely and checks the server before a const component = createOwned(EditRegistry, { renderer: inertRenderer(), intl: EmberObject.create({t(key) { return key; }}), - modalService: EmberObject.create({modalOpts: EmberObject.create({registry, credential: null, registries: A([registry])})}), + projects, + modalService: EmberObject.create({modalOpts: EmberObject.create({registry, credential: null, registries: A([registry]), projectId: 'project-1'})}), }, 'component'); assert.true(component.get('missingCredential'), 'no clone() call on a missing credential'); @@ -61,12 +72,47 @@ test('a registry without credentials opens safely and checks the server before a assert.strictEqual(reads, 2); assert.strictEqual(saves, 1, 'only the still-empty orphan path submits a credential'); assert.strictEqual(saveOptions, undefined, 'the missing-credential POST path keeps its original save call'); + + canCreate = false; + projects.incrementProperty('schemaLoadGeneration'); + assert.false(component.get('canSaveCredential'), 'schema revocation disables the open modal'); + try { + await component.doSave(); + assert.ok(false, 'a revoked creator must not submit'); + } catch (error) { + assert.strictEqual(error.status, 403); + assert.strictEqual(error.messageKey, 'resourceSaveError.unavailable'); + } + assert.strictEqual(reads, 2, 'a denied save makes no collection request'); + assert.strictEqual(saves, 1, 'a denied save makes no POST'); + + canCreate = true; + projects.incrementProperty('schemaLoadGeneration'); + pendingRead = defer(); + const submission = component.doSave(); + canCreate = false; + projects.incrementProperty('schemaLoadGeneration'); + pendingRead.resolve(A([])); + try { + await submission; + assert.ok(false, 'revocation during the collection read must stop POST'); + } catch (error) { + assert.strictEqual(error.status, 403); + } + assert.strictEqual(reads, 3); + assert.strictEqual(saves, 1); destroyOwned(component); }); test('editing an existing credential sends only the editable fields', async function(assert) { const saveCalls = []; let registrySaves = 0; + let reads = 0; + let freshCredential = EmberObject.create({ + id: 'credential-1', + registryId: 'registry-1', + actionLinks: {update: '/credential-1'}, + }); const saved = EmberObject.create({id: 'credential-1'}); const editedCredential = EmberObject.create({ id: 'credential-1', @@ -85,16 +131,32 @@ test('editing an existing credential sends only the editable fields', async func const registry = EmberObject.create({ id: 'registry-1', serverAddress: 'registry.invalid.test', + store: { + find(type, id, options) { + reads++; + assert.strictEqual(type, 'registrycredential'); + assert.strictEqual(id, 'credential-1'); + assert.true(options.forceReload, 'PUT capability is fetched again before saving'); + return resolve(freshCredential); + }, + }, clone() { return EmberObject.create({id: this.get('id')}); }, save() { registrySaves++; return resolve(this); }, }); + const originalCredential = EmberObject.create({ + actionLinks: {update: '/credential-1'}, + clone() { return editedCredential; }, + }); + const projects = EmberObject.create({current: EmberObject.create({id: 'project-1'}), schemaProjectId: 'project-1'}); const component = createOwned(EditRegistry, { renderer: inertRenderer(), intl: EmberObject.create({t(key) { return key; }}), + projects, modalService: EmberObject.create({modalOpts: EmberObject.create({ registry, - credential: EmberObject.create({clone() { return editedCredential; }}), + credential: originalCredential, registries: A([registry]), + projectId: 'project-1', })}), }, 'component'); @@ -112,6 +174,41 @@ test('editing an existing credential sends only the editable fields', async func publicValue: 'edited-user', secretValue: '', }, 'an explicitly empty password input is retained'); + assert.strictEqual(reads, 2, 'each PUT checks current child capabilities'); + + freshCredential = EmberObject.create({ + id: 'credential-1', + registryId: 'registry-1', + actionLinks: {}, + }); + try { + await component.doSave(); + assert.ok(false, 'a revoked child update link must stop PUT'); + } catch (error) { + assert.strictEqual(error.status, 403); + assert.strictEqual(error.messageKey, 'resourceSaveError.unavailable'); + } + assert.strictEqual(saveCalls.length, 2); + + freshCredential = EmberObject.create({ + id: 'credential-1', + registryId: 'another-registry', + actionLinks: {update: '/credential-1'}, + }); + try { + await component.doSave(); + assert.ok(false, 'a credential linked to another registry must not be updated'); + } catch (error) { + assert.strictEqual(error.status, 404); + assert.strictEqual(error.messageKey, 'resourceSaveError.unavailable'); + } + assert.strictEqual(saveCalls.length, 2); + + originalCredential.set('actionLinks', {}); + assert.false(component.get('canSaveCredential'), 'a cached link revocation disables Save'); + originalCredential.set('actionLinks', {update: '/credential-1'}); + projects.set('current.id', 'project-2'); + assert.false(component.get('canSaveCredential'), 'an open editor cannot save into another project'); assert.strictEqual(registrySaves, 0, 'the registry itself is not saved by this editor'); destroyOwned(component); }); diff --git a/tests/unit/components/edit-service-test.js b/tests/unit/components/edit-service-test.js index ac562b29fc..a980108d8a 100644 --- a/tests/unit/components/edit-service-test.js +++ b/tests/unit/components/edit-service-test.js @@ -40,6 +40,7 @@ function makeComponent({save, setLinks}, closed) { }); const component = createOwned(EditService, { renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), modalService: EmberObject.create({ modalOpts: original, modalVisible: true, diff --git a/tests/unit/components/info-multi-stats-test.js b/tests/unit/components/info-multi-stats-test.js index 70e9b3c8e0..31c090b09c 100644 --- a/tests/unit/components/info-multi-stats-test.js +++ b/tests/unit/components/info-multi-stats-test.js @@ -90,6 +90,29 @@ test('unchanged parent stats survive child-route attribute refreshes', function( destroyOwned(component); }); +test('an unavailable stats link shows a message instead of a permanent spinner', function(assert) { + let component = createOwned(InfoMultiStatsComponent, { + renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), + statsSocket: EmberObject.create({available: true, connectError: true}), + renderOk: false, + }, 'component'); + + assert.true(component.get('showUnavailable'), 'the unavailable message is visible'); + assert.false(component.get('waitingForData'), 'connecting text is hidden'); + assert.strictEqual(component.get('unavailableMessage'), 'infoMultiStats.utilizationStats', + 'transport failures use a neutral message'); + + for (const [status, key] of [[401, 'authError'], [403, 'permissionError'], [404, 'notFound']]) { + component.set('statsSocket.connectErrorStatus', status); + assert.strictEqual(component.get('unavailableMessage'), `infoMultiStats.${key}`, + `${status} has its own useful message`); + } + + component.set('statsSocket', null); + destroyOwned(component); +}); + test('Billboard redraws seeded host series without point-node errors', async function(assert) { var target = document.createElement('div'); target.style.width = '540px'; diff --git a/tests/unit/components/new-catalog-upgrade-permissions-test.js b/tests/unit/components/new-catalog-upgrade-permissions-test.js new file mode 100644 index 0000000000..f28ecd6fc6 --- /dev/null +++ b/tests/unit/components/new-catalog-upgrade-permissions-test.js @@ -0,0 +1,144 @@ +import EmberObject from '@ember/object'; +import { resolve } from 'rsvp'; +import { module, test } from 'qunit'; +import NewCatalog from 'ui/components/new-catalog/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | new catalog upgrade permissions'); + +test('same-page upgrade refuses a missing instance action before sending a request', async function(assert) { + let writes = 0; + let stack = EmberObject.create({ + id: '1st-qa', actionLinks: {}, dockerCompose: 'services: {}', + rancherCompose: 'services: {}', environment: {}, + doAction(action) { + assert.strictEqual(action, 'upgrade'); + writes++; + return resolve('upgraded'); + }, + }); + let component = createOwned(NewCatalog, { + stackResource: stack, + projectId: 'project-1', + selectedTemplateModel: EmberObject.create({id: 'version-qa'}), + intl: EmberObject.create({t(key) { return key; }}), + catalog: EmberObject.create(), + projects: EmberObject.create({ + current: EmberObject.create({id: 'project-1'}), + schemaProjectId: 'project-1', + }), + settings: EmberObject.create(), + renderer: inertRenderer(), + }, 'component'); + + assert.throws(() => component.doSave(), + (error) => error.status === 403 && error.messageKey === 'newCatalog.upgradeUnavailable'); + assert.strictEqual(writes, 0, 'no upgrade request was sent'); + + stack.set('actionLinks.upgrade', '/upgrade'); + assert.strictEqual(await component.doSave(), 'upgraded'); + assert.strictEqual(writes, 1, 'one explicitly authorized upgrade request was sent'); + destroyOwned(component); +}); + +test('same-page stack creation rechecks this project before POST', async function(assert) { + let writes = 0; + let allowed = true; + let projects = EmberObject.create({ + current: EmberObject.create({id: 'project-1'}), + schemaProjectId: 'project-1', + schemaLoadGeneration: 1, + canCreateResource(type) { + assert.strictEqual(type, 'stack'); + return allowed && this.get('current.id') === this.get('schemaProjectId'); + }, + }); + let component = createOwned(NewCatalog, { + stackResource: EmberObject.create({ + type: 'stack', + save() { + writes++; + return resolve('created'); + }, + }), + projectId: 'project-1', + intl: EmberObject.create({t(key) { return key; }}), + catalog: EmberObject.create(), + projects, + settings: EmberObject.create(), + renderer: inertRenderer(), + }, 'component'); + + assert.true(component.get('canSubmit')); + assert.strictEqual(await component.doSave(), 'created'); + assert.strictEqual(writes, 1); + + allowed = false; + projects.incrementProperty('schemaLoadGeneration'); + assert.false(component.get('canSubmit'), 'the open form disables Save after capability revocation'); + assert.throws(() => component.doSave(), + (error) => error.status === 403 && error.messageKey === 'newCatalog.permissionDenied'); + assert.strictEqual(writes, 1, 'revocation sends no second POST'); + + allowed = true; + projects.set('current.id', 'project-2'); + projects.set('schemaProjectId', 'project-2'); + assert.false(component.get('canSubmit'), 'a creator in another project cannot submit the old form'); + assert.throws(() => component.doSave(), + (error) => error.status === 403 && error.messageKey === 'newCatalog.projectChanged'); + assert.strictEqual(writes, 1, 'project switching sends no POST'); + + component.set('actuallySave', false); + assert.true(component.get('canSubmit'), 'the configure-only modal does not write a stack'); + destroyOwned(component); +}); + +test('catalog required-field errors use the selected English, Chinese, or Japanese copy', async function(assert) { + for (let locale of ['en-us', 'zh-tw', 'ja-jp']) { + let response = await fetch(`/translations/${locale}.json`); + assert.ok(response.ok, `${locale} translations are available`); + let messages = await response.json(); + let t = (key, args={}) => messages[key].replace('{key}', args.key || ''); + let component = createOwned(NewCatalog, { + stackResource: EmberObject.create({name: ''}), + selectedTemplateModel: EmberObject.create({ + questions: [EmberObject.create({required: true, answer: null, label: 'Database name'})], + }), + intl: EmberObject.create({t}), + catalog: EmberObject.create(), + projects: EmberObject.create(), + settings: EmberObject.create(), + renderer: inertRenderer(), + }, 'component'); + + assert.false(component.validate()); + assert.deepEqual(component.get('errors'), [ + t('validation.required', {key: t('generic.name')}), + t('validation.required', {key: 'Database name'}), + ], `${locale} translates both client-side required errors`); + destroyOwned(component); + } +}); + +test('completed save never navigates a stack ID into a newly selected project', function(assert) { + const transitions = []; + const projects = EmberObject.create({current: EmberObject.create({id: 'project-1'})}); + const component = createOwned(NewCatalog, { + stackResource: EmberObject.create({id: '1st-qa', system: false}), + projectId: 'project-1', + projects, + router: EmberObject.create({transitionTo(...args) { transitions.push(args); }}), + intl: EmberObject.create({t(key) { return key; }}), + catalog: EmberObject.create(), + settings: EmberObject.create(), + renderer: inertRenderer(), + }, 'component'); + + component.doneSaving(); + assert.deepEqual(transitions, [['stack', 'project-1', '1st-qa']], 'the originating project is used'); + projects.set('current.id', 'project-2'); + component.doneSaving(); + assert.strictEqual(transitions.length, 1, 'a late save does not redirect from the newer project'); + destroyOwned(component); +}); diff --git a/tests/unit/components/project-upgrade-permissions-test.js b/tests/unit/components/project-upgrade-permissions-test.js new file mode 100644 index 0000000000..fdd9c2810e --- /dev/null +++ b/tests/unit/components/project-upgrade-permissions-test.js @@ -0,0 +1,123 @@ +import EmberObject from '@ember/object'; +import { defer, reject, resolve } from 'rsvp'; +import { module, test } from 'qunit'; + +import ProjectUpgrade from 'ui/components/project-upgrade/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | project upgrade permissions'); + +test('upgrade requires both owner role and this project ID upgrade action', async function(assert) { + let writes = 0; + let owner = true; + let project = EmberObject.create({ + id: '1a21', + actionLinks: {}, + doAction(action) { + assert.strictEqual(action, 'upgrade'); + writes++; + return resolve('upgraded'); + }, + }); + let projects = EmberObject.create({current: project, schemaProjectId: '1a21', schemaLoadGeneration: 1}); + let component = createOwned(ProjectUpgrade, { + access: EmberObject.create({isOwner() { return owner; }}), + intl: EmberObject.create({t(key) { return key; }}), + projects, + renderer: inertRenderer(), + settings: EmberObject.create(), + }, 'component'); + + assert.false(component.get('canUpgrade'), 'owner cannot upgrade without this ID action link'); + await component.get('actions').upgrade.call(component); + assert.strictEqual(writes, 0, 'disabled button path cannot submit'); + + project.set('actionLinks', {upgrade: '/projects/1a21?action=upgrade'}); + assert.true(component.get('canUpgrade'), 'owner can upgrade an advertised resource action'); + assert.strictEqual(await component.get('actions').upgrade.call(component), 'upgraded'); + assert.strictEqual(writes, 1, 'submits one upgrade and returns its completion promise'); + + owner = false; + projects.incrementProperty('schemaLoadGeneration'); + assert.false(component.get('canUpgrade'), 'role change on the same project invalidates the cached owner check'); + await component.get('actions').upgrade.call(component); + assert.strictEqual(writes, 1); + + owner = true; + projects.set('schemaProjectId', null); + assert.false(component.get('canUpgrade'), 'a schema reload cannot borrow the earlier owner grant'); + projects.set('schemaProjectId', '1a21'); + projects.incrementProperty('schemaLoadGeneration'); + assert.true(component.get('canUpgrade')); + + owner = false; + project.set('id', '1a22'); + assert.false(component.get('canUpgrade'), 'non-owner cannot use the action even if advertised'); + await component.get('actions').upgrade.call(component); + assert.strictEqual(writes, 1); + destroyOwned(component); +}); + +test('failed upgrade explains the error on the page and prevents duplicate submission', async function(assert) { + let pending = defer(); + let writes = 0; + let project = EmberObject.create({ + id: '1a21', + actionLinks: {upgrade: '/projects/1a21?action=upgrade'}, + doAction() { + writes++; + return pending.promise; + }, + }); + let component = createOwned(ProjectUpgrade, { + access: EmberObject.create({isOwner() { return true; }}), + intl: EmberObject.create({t(key) { return key; }}), + projects: EmberObject.create({current: project, schemaProjectId: '1a21'}), + renderer: inertRenderer(), + settings: EmberObject.create(), + }, 'component'); + + let first = component.get('actions').upgrade.call(component); + await component.get('actions').upgrade.call(component); + pending.reject({status: 403}); + assert.false(await first); + assert.strictEqual(writes, 1, 'a second click does not send another upgrade'); + assert.strictEqual(component.get('errorMessage'), 'resourceSaveError.unavailable'); + assert.false(component.get('isUpgrading')); + + project.set('doAction', () => reject({status: 404})); + assert.false(await component.get('actions').upgrade.call(component)); + assert.strictEqual(component.get('errorMessage'), 'resourceSaveError.unavailable'); + destroyOwned(component); +}); + +test('a project switch before the queued upgrade cannot write either project', async function(assert) { + let writes = []; + const oldProject = EmberObject.create({ + id: '1a21', + actionLinks: {upgrade: '/projects/1a21?action=upgrade'}, + doAction() { writes.push('old'); return resolve(); }, + }); + const newProject = EmberObject.create({ + id: '1a22', + actionLinks: {upgrade: '/projects/1a22?action=upgrade'}, + doAction() { writes.push('new'); return resolve(); }, + }); + const projects = EmberObject.create({current: oldProject, schemaProjectId: '1a21'}); + const component = createOwned(ProjectUpgrade, { + access: EmberObject.create({isOwner() { return true; }}), + intl: EmberObject.create({t(key) { return key; }}), + projects, + renderer: inertRenderer(), + settings: EmberObject.create(), + }, 'component'); + + const pending = component.get('actions').upgrade.call(component); + projects.setProperties({current: newProject, schemaProjectId: '1a22'}); + assert.false(await pending, 'stale click settles as a localized denial'); + assert.deepEqual(writes, [], 'neither the old nor the newly selected project is modified'); + assert.strictEqual(component.get('errorMessage'), 'resourceSaveError.unavailable'); + assert.false(component.get('isUpgrading')); + destroyOwned(component); +}); diff --git a/tests/unit/components/stack-add-permissions-test.js b/tests/unit/components/stack-add-permissions-test.js new file mode 100644 index 0000000000..305040d134 --- /dev/null +++ b/tests/unit/components/stack-add-permissions-test.js @@ -0,0 +1,64 @@ +import EmberObject from '@ember/object'; +import { module, test } from 'qunit'; + +import StackHeader from 'ui/components/stack-header/component'; +import StackSection from 'ui/components/stack-section/component'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Component | stack add permissions'); + +[ + ['header', StackHeader], + ['section', StackSection], +].forEach(([name, Factory]) => { + test(`${name} offers each service subtype only when its create schema allows it`, function(assert) { + let allowed = new Set(['service']); + let project = EmberObject.create({id: '1a21', isWindows: false, virtualMachine: true}); + let projects = EmberObject.create({ + current: project, + schemaProjectId: '1a21', + schemaLoadGeneration: 1, + canCreateResource(type) { + return this.get('current.id') === this.get('schemaProjectId') && allowed.has(type.toLowerCase()); + }, + }); + let component = createOwned(Factory, { + projects, + settings: EmberObject.create(), + prefs: EmberObject.create(), + model: EmberObject.create({id: '1st1'}), + renderer: inertRenderer(), + }, 'component'); + + assert.deepEqual(component.get('createOptions'), { + service: true, balancer: false, alias: false, external: false, + vm: true, other: true, any: true, + }, 'ordinary Service and VM use the service create capability'); + + allowed = new Set(['loadbalancerservice', 'dnsservice']); + projects.incrementProperty('schemaLoadGeneration'); + assert.deepEqual(component.get('createOptions'), { + service: false, balancer: true, alias: true, external: false, + vm: false, other: true, any: true, + }, 'Balancer and Alias stay available without ordinary Service create'); + + project.set('isWindows', true); + assert.deepEqual(component.get('createOptions'), { + service: false, balancer: false, alias: true, external: false, + vm: false, other: true, any: true, + }, 'Windows still suppresses Balancer and VM'); + + allowed = new Set(['externalservice']); + projects.incrementProperty('schemaLoadGeneration'); + assert.deepEqual(component.get('createOptions'), { + service: false, balancer: false, alias: false, external: true, + vm: false, other: true, any: true, + }, 'External Service has its own create capability'); + + projects.set('schemaProjectId', 'different-project'); + assert.false(component.get('createOptions.any'), 'stale schema grants no create entry'); + + destroyOwned(component); + }); +}); diff --git a/tests/unit/components/webhook-new-receiver-clone-test.js b/tests/unit/components/webhook-new-receiver-clone-test.js index d277f4f8ed..c81f94a3a0 100644 --- a/tests/unit/components/webhook-new-receiver-clone-test.js +++ b/tests/unit/components/webhook-new-receiver-clone-test.js @@ -21,6 +21,7 @@ test('a cloned scaleHost receiver drops every inactive driver configuration', fu run(() => { component = createOwned(NewReceiver, { renderer: inertRenderer(), model, + intl: EmberObject.create({t(key) { return key; }}), projects: EmberObject.create({}), webhookStore: {createRecord(payload) { return payload; }}, }, 'component'); diff --git a/tests/unit/mixins/new-or-edit-test.js b/tests/unit/mixins/new-or-edit-test.js index f20a34d7c1..b771de3133 100644 --- a/tests/unit/mixins/new-or-edit-test.js +++ b/tests/unit/mixins/new-or-edit-test.js @@ -7,6 +7,33 @@ import NewOrEdit from 'ui/mixins/new-or-edit'; module('Unit | Mixin | new or edit'); +test('the save error action uses optional localized formatting', function(assert) { + let intl = {t(key) { + return { + 'resourceSaveError.unavailable': '無法完成儲存。', + 'resourceSaveError.validation': '伺服器未接受變更。', + 'resourceSaveError.failed': '儲存失敗。', + 'viewEditProject.error.projectNotSaved': '環境設定未儲存。', + }[key]; + }}; + let Subject = EmberObject.extend(NewOrEdit, {intl}); + let subject = Subject.create(); + + subject.get('actions').error.call(subject, {status: 403}); + assert.deepEqual(subject.get('errors'), ['無法完成儲存。']); + subject.get('actions').error.call(subject, {status: 404, message: 'Private ID exists'}); + assert.deepEqual(subject.get('errors'), ['無法完成儲存。']); + subject.get('actions').error.call(subject, { + status: 403, + messageKey: 'viewEditProject.error.projectNotSaved', + message: '環境設定未儲存。', + }); + assert.deepEqual(subject.get('errors'), ['環境設定未儲存。'], 'Project keeps its stage-specific message'); + subject.get('actions').error.call(subject, {status: 422, fieldName: 'name', detail: 'already used'}); + assert.deepEqual(subject.get('errors'), ['伺服器未接受變更。 name: already used']); + run(() => subject.destroy()); +}); + function subjectWith(overrides={}) { let Subject = EmberObject.extend(NewOrEdit, { displayedErrors: null, diff --git a/tests/unit/mixins/upgrade-component-permissions-test.js b/tests/unit/mixins/upgrade-component-permissions-test.js new file mode 100644 index 0000000000..de57cdad9c --- /dev/null +++ b/tests/unit/mixins/upgrade-component-permissions-test.js @@ -0,0 +1,34 @@ +import EmberObject from '@ember/object'; +import { run } from '@ember/runloop'; +import { module, test } from 'qunit'; +import UpgradeComponent from 'ui/mixins/upgrade-component'; + +module('Unit | Mixin | upgrade component permissions'); + +test('upgrade and finish use their own instance action links', function(assert) { + let model = EmberObject.create({state: 'active', actionLinks: {}}); + let subject = EmberObject.extend(UpgradeComponent).create({ + model, + intl: EmberObject.create(), + catalog: EmberObject.create(), + userStore: EmberObject.create(), + }); + + subject.set('upgradeStatus', 'available'); + assert.false(subject.get('canApplyUpgrade'), 'create or update schema does not imply upgrade'); + assert.strictEqual(subject.get('color'), 'btn-disabled'); + + subject.set('upgradeStatus', 'none'); + assert.strictEqual(subject.get('color'), 'hide', 'stacks without a catalog upgrade do not show a disabled button'); + subject.set('upgradeStatus', 'available'); + + model.set('actionLinks.upgrade', '/upgrade'); + assert.true(subject.get('canApplyUpgrade'), 'instance upgrade link enables upgrade'); + + subject.set('upgradeStatus', 'upgraded'); + assert.false(subject.get('canApplyUpgrade'), 'upgrade link does not imply finish'); + model.set('actionLinks.finishupgrade', '/finishupgrade'); + assert.true(subject.get('canApplyUpgrade'), 'finish requires its own link'); + + run(() => subject.destroy()); +}); diff --git a/tests/unit/models/container-permissions-test.js b/tests/unit/models/container-permissions-test.js new file mode 100644 index 0000000000..95083e3624 --- /dev/null +++ b/tests/unit/models/container-permissions-test.js @@ -0,0 +1,22 @@ +import { module, test } from 'qunit'; +import Container from 'ui/models/container'; +import { destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Model | container permissions'); + +test('remove action requires this container ID to expose a remove link', function(assert) { + let container = Container.create({state: 'running', actionLinks: {}}); + + assert.false(container.get('canDelete'), 'readable running container without DELETE capability cannot be removed'); + assert.false(container.get('availableActions').find((action) => action.action === 'promptDelete').enabled, + 'the unavailable remove choice is filtered from the menu'); + + container.set('actionLinks', {remove: 'https://example.invalid/remove'}); + assert.true(container.get('canDelete'), 'a running container with its own remove link can be removed'); + assert.true(container.get('availableActions').find((action) => action.action === 'promptDelete').enabled, + 'the permitted remove choice appears in the menu'); + + container.set('state', 'removed'); + assert.false(container.get('canDelete'), 'removed containers remain non-removable'); + destroyOwned(container); +}); diff --git a/tests/unit/models/registry-edit-permissions-test.js b/tests/unit/models/registry-edit-permissions-test.js new file mode 100644 index 0000000000..dd55a1d874 --- /dev/null +++ b/tests/unit/models/registry-edit-permissions-test.js @@ -0,0 +1,105 @@ +import { A } from '@ember/array'; +import EmberObject from '@ember/object'; +import { defer, resolve } from 'rsvp'; +import { module, test } from 'qunit'; + +import Registry from 'ui/models/registry'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; + +module('Unit | Model | registry edit permissions'); + +test('Edit follows the credential write capability and rechecks it when opening', async function(assert) { + let credential = EmberObject.create({registryId: 'registry-1', actionLinks: {}}); + let credentials = A([credential]); + let canCreate = true; + let findCalls = 0; + let nextFind = null; + let opened = []; + let projects = EmberObject.create({ + current: EmberObject.create({id: 'project-1'}), + schemaProjectId: 'project-1', + schemaLoadGeneration: 0, + canCreateResource(type) { + assert.strictEqual(type, 'registryCredential'); + return this.get('current.id') === this.get('schemaProjectId') && canCreate; + }, + }); + let registry = createOwned(Registry, { + id: 'registry-1', + actionLinks: {update: '/registry-1'}, + projects, + store: { + all(type) { + assert.strictEqual(type, 'registrycredential'); + return credentials; + }, + find(type) { + assert.strictEqual(type, 'registry'); + findCalls++; + return nextFind || resolve(A([registry])); + }, + }, + modalService: { + toggleModal(name, options) { + assert.strictEqual(name, 'edit-registry'); + opened.push(options); + }, + }, + }, 'model'); + const editEnabled = () => registry.get('availableActions').findBy('action', 'edit').enabled; + const open = () => registry.actions.edit.call(registry); + + assert.false(editEnabled(), 'the parent update link cannot authorize a child PUT'); + await open(); + assert.strictEqual(findCalls, 0); + + credential.set('actionLinks', {update: '/credential-1'}); + registry.set('actionLinks', {}); + assert.true(editEnabled(), 'the child update link allows editing without a parent update link'); + await open(); + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0].get('credential'), credential); + assert.strictEqual(opened[0].get('projectId'), 'project-1'); + + credential.set('actionLinks', {}); + assert.false(editEnabled(), 'revocation hides Edit when the modal is reopened'); + await open(); + assert.strictEqual(opened.length, 1); + + credentials.removeObject(credential); + assert.true(editEnabled(), 'a missing credential uses the current schema POST capability'); + await open(); + assert.strictEqual(opened.length, 2); + assert.strictEqual(opened[1].get('credential'), undefined); + + canCreate = false; + projects.incrementProperty('schemaLoadGeneration'); + assert.false(editEnabled(), 'revoked POST hides the missing-credential editor'); + await open(); + assert.strictEqual(opened.length, 2); + + canCreate = true; + projects.incrementProperty('schemaLoadGeneration'); + const pending = defer(); + nextFind = pending.promise; + let opening = open(); + canCreate = false; + projects.incrementProperty('schemaLoadGeneration'); + pending.resolve(A([registry])); + await opening; + assert.strictEqual(opened.length, 2, 'permission revoked while loading never opens the modal'); + + credential.set('actionLinks', {update: '/credential-1'}); + credentials.pushObject(credential); + const projectSwitch = defer(); + nextFind = projectSwitch.promise; + opening = open(); + projects.set('current.id', 'project-2'); + projectSwitch.resolve(A([registry])); + await opening; + assert.strictEqual(opened.length, 2, 'switching projects while loading never opens the old registry'); + assert.false(editEnabled(), 'a stale registry has no Edit action without a current project'); + + destroyOwned(registry); + destroyOwned(projects); +}); diff --git a/tests/unit/models/secondary-resource-actions-test.js b/tests/unit/models/secondary-resource-actions-test.js index e4bd7e4be3..760743be02 100644 --- a/tests/unit/models/secondary-resource-actions-test.js +++ b/tests/unit/models/secondary-resource-actions-test.js @@ -4,11 +4,9 @@ import { module, test } from 'qunit'; import Secret from 'ui/models/secret'; import Certificate from 'ui/models/certificate'; -import Registry from 'ui/models/registry'; const resources = [ ['certificate', Certificate], - ['registry', Registry], ]; module('Unit | Model | secondary resource actions'); diff --git a/tests/unit/models/service-scale-test.js b/tests/unit/models/service-scale-test.js index fc26f84a92..67a24198f2 100644 --- a/tests/unit/models/service-scale-test.js +++ b/tests/unit/models/service-scale-test.js @@ -1,18 +1,32 @@ -import { run } from '@ember/runloop'; -import { resolve } from 'rsvp'; +import EmberObject from '@ember/object'; +import { cancel, run } from '@ember/runloop'; +import { defer, resolve } from 'rsvp'; import { module, test } from 'qunit'; import Service from 'ui/models/service'; module('Unit | Model | service scale'); +function projectContext() { + return { + 'tab-session': EmberObject.create({projectId: '1a21'}), + projects: EmberObject.create({ + current: EmberObject.create({id: '1a21'}), + schemaProjectId: '1a21', + }), + }; +} + test('quick scale buttons PUT only the latest scale', async function(assert) { const requests = []; function makeService(id) { return Service.create({ + ...projectContext(), id, type: 'service', links: {self: `/v1/services/${id}`}, + actionLinks: {update: `/v1/services/${id}`}, + intl: {t(key) { return key; }}, scale: 2, launchConfig: {imageUuid: 'docker:example', volumeDriver: ''}, upgrade: {inServiceStrategy: {batchSize: 1}}, @@ -45,3 +59,136 @@ test('quick scale buttons PUT only the latest scale', async function(assert) { scaleDown.destroy(); }); }); + +test('readonly service displays its scale but cannot submit scale writes', function(assert) { + let calls = 0; + let resource = Service.create({ + ...projectContext(), + id: '1s-readonly', type: 'service', scale: 2, + actionLinks: {}, + saveScale() { calls++; }, + }); + + assert.false(resource.get('canScale')); + resource.send('scaleUp'); + resource.send('scaleDown'); + assert.strictEqual(resource.get('scale'), 2, 'the readonly value is unchanged'); + assert.strictEqual(calls, 0, 'neither button path attempts a write'); + + resource.set('actionLinks', {update: '/v1/services/1s-readonly'}); + assert.true(resource.get('canScale'), 'the same service can scale when its ID advertises update'); + resource.set('scale', 1); + resource.send('scaleDown'); + assert.strictEqual(resource.get('scale'), 1, 'scale down never crosses the visible minimum'); + assert.strictEqual(calls, 0); + resource.destroy(); +}); + +test('a queued scale write rechecks the instance permission before PUT', async function(assert) { + let writes = 0; + let notices = []; + let resource = Service.create({ + ...projectContext(), + id: '1s-queued', type: 'service', scale: 3, + actionLinks: {update: '/v1/services/1s-queued'}, + intl: {t(key) { return key; }}, + growl: {fromError(title, error) { notices.push({title, status: error.status}); }}, + save() { + writes++; + return resolve(); + }, + }); + + resource.set('actionLinks', {}); + await resource.persistScale(); + assert.strictEqual(writes, 0, 'a permission removed before debounce fires never writes'); + assert.deepEqual(notices, [{title: 'resourceSaveError.scaleFailed', status: 403}], + 'the user receives a localized permission explanation'); + + resource.set('actionLinks', {update: '/v1/services/1s-queued'}); + await resource.persistScale(); + assert.strictEqual(writes, 1, 'the authorized instance can submit'); + resource.destroy(); +}); + +test('a denied scale submit restores the last saved value and remains retryable', async function(assert) { + let notices = []; + let requests = 0; + let resource = Service.create({ + ...projectContext(), + id: '1s-rejected', type: 'service', scale: 4, scaleBeforePending: 2, + actionLinks: {update: '/v1/services/1s-rejected'}, + intl: {t(key) { return key; }}, + growl: {fromError(title, error) { notices.push({title, status: error.status}); }}, + save() { + requests++; + throw {status: 422}; + }, + }); + + assert.false(await resource.persistScale(), 'a synchronous save failure is handled'); + assert.strictEqual(requests, 1); + assert.strictEqual(resource.get('scale'), 2, 'the unsaved value is not left on screen'); + assert.strictEqual(resource.get('scaleBeforePending'), null); + assert.false(resource.get('scaleSaving')); + assert.true(resource.get('canScale'), 'the same button is usable after correction'); + assert.deepEqual(notices, [{title: 'resourceSaveError.scaleFailed', status: 422}]); + resource.destroy(); +}); + +test('an in-flight scale submit owns its value and blocks duplicate clicks', async function(assert) { + let pending = defer(); + let requests = 0; + let resource = Service.create({ + ...projectContext(), + id: '1s-pending', type: 'service', scale: 4, scaleBeforePending: 2, + actionLinks: {update: '/v1/services/1s-pending'}, + intl: {t(key) { return key; }}, + save() { + requests++; + return pending.promise; + }, + }); + + let result = resource.persistScale(); + assert.false(resource.get('canScale'), 'the button is disabled during the request'); + resource.send('scaleUp'); + resource.send('scaleDown'); + assert.strictEqual(resource.get('scale'), 4, 'duplicate clicks cannot change the in-flight value'); + pending.resolve(); + assert.true(await result); + assert.strictEqual(requests, 1, 'one server write for the debounced value'); + assert.strictEqual(resource.get('scale'), 4); + assert.strictEqual(resource.get('scaleBeforePending'), null); + assert.true(resource.get('canScale')); + resource.destroy(); +}); + +test('a queued scale click cannot write after switching projects', async function(assert) { + let writes = 0; + const notices = []; + const resource = Service.create({ + ...projectContext(), + id: '1s-old', type: 'service', scale: 2, + actionLinks: {update: '/v1/services/1s-old'}, + intl: {t(key) { return key; }}, + growl: {fromError(title, error) { notices.push({title, status: error.status}); }}, + save() { writes++; return resolve(); }, + }); + resource.send('scaleUp'); + assert.strictEqual(resource.get('scale'), 3, 'the click has a local pending value'); + assert.strictEqual(resource.get('scaleRequestProjectId'), '1a21'); + cancel(resource.get('scaleTimer')); + resource.set('scaleTimer', null); + resource.get('tab-session').set('projectId', '1a22'); + resource.get('projects').setProperties({ + current: EmberObject.create({id: '1a22'}), + schemaProjectId: '1a22', + }); + + assert.false(await resource.persistScale()); + assert.strictEqual(writes, 0, 'the queued click cannot submit against the old project'); + assert.strictEqual(resource.get('scale'), 2, 'the unsaved value is restored'); + assert.deepEqual(notices, [{title: 'resourceSaveError.scaleFailed', status: 403}]); + resource.destroy(); +}); diff --git a/tests/unit/services/growl-test.js b/tests/unit/services/growl-test.js new file mode 100644 index 0000000000..2ce40f5fce --- /dev/null +++ b/tests/unit/services/growl-test.js @@ -0,0 +1,31 @@ +import { module, test } from 'qunit'; +import Growl from 'ui/services/growl'; + +module('Unit | Service | growl'); + +test('denied deletes and actions use neutral, private-safe copy in all supported write locales', async function(assert) { + for (let locale of ['en-us', 'zh-tw', 'ja-jp']) { + let response = await fetch(`/translations/${locale}.json`); + assert.ok(response.ok, `${locale} translations are available`); + let messages = await response.json(); + let notifications = []; + let growl = Growl.create({ + intl: {t(key) { return messages[key]; }}, + error(title, body) { notifications.push({title, body}); }, + }); + + assert.ok(messages['resourceSaveError.actionUnavailable'], `${locale} has neutral action copy`); + for (let status of [403, 404]) { + growl.fromError('Delete failed', {status, message: 'private resource ID 1st-secret'}); + assert.deepEqual(notifications.pop(), { + title: 'Delete failed', + body: messages['resourceSaveError.actionUnavailable'], + }, `${locale} ${status} does not call a delete failure a save or expose its resource ID`); + } + + growl.fromError('Validation failed', {status: 422, fieldName: 'name', detail: 'already used'}); + assert.ok(notifications.pop().body.startsWith(messages['resourceSaveError.validation']), + `${locale} validation still uses the existing localized formatter`); + growl.destroy(); + } +}); diff --git a/tests/unit/utils/errors-test.js b/tests/unit/utils/errors-test.js index ea53c0662d..73d72717b0 100644 --- a/tests/unit/utils/errors-test.js +++ b/tests/unit/utils/errors-test.js @@ -1,4 +1,5 @@ import { module, test } from 'qunit'; +import ApiError from 'ember-api-store/models/error'; import Errors from 'ui/utils/errors'; module('Unit | Utility | errors'); @@ -28,3 +29,48 @@ test('finds authentication status codes in nested request failures', function(as assert.strictEqual(Errors.status({body: '{"status":502}'}), 502, 'a JSON body status is found'); assert.strictEqual(Errors.status({xhr: {status: 0}}), null, 'a network failure is not authentication failure'); }); + +test('save errors have useful reviewed copy in English, Traditional Chinese, and Japanese', async function(assert) { + for (let locale of ['en-us', 'zh-tw', 'ja-jp']) { + let response = await fetch(`/translations/${locale}.json`); + assert.ok(response.ok, `${locale} translations are available`); + let messages = await response.json(); + let lookup = (key) => messages[key]; + let intl = {t: lookup}; + + for (let key of ['unavailable', 'validation', 'failed']) { + assert.ok(lookup(`resourceSaveError.${key}`), `${locale} has ${key} copy`); + } + for (let key of [ + 'projectTemplateUnavailable', 'stackUnavailable', 'stackFailed', + 'accountsUnavailable', 'accountsFailed', + 'accountSecurityUnavailable', 'accountSecurityFailed' + ]) { + assert.ok(lookup(`resourceLoadError.${key}`), `${locale} has ${key} load copy`); + } + + let denied = ApiError.create({status: 403, message: 'Resource 1st1 exists', detail: 'private ID'}); + let missing = ApiError.create({status: 404, message: 'Resource 1st1 does not exist'}); + assert.strictEqual(Errors.status(denied), 403, 'the API error model exposes its status'); + assert.strictEqual(Errors.stringify(denied, intl), lookup('resourceSaveError.unavailable'), + `${locale} does not expose the denied resource's message or ID`); + assert.strictEqual(Errors.stringify(missing, intl), lookup('resourceSaveError.unavailable'), + `${locale} gives a missing resource the same visible explanation`); + assert.strictEqual(Errors.stringify({status: 403}, intl), lookup('resourceSaveError.unavailable'), + `${locale} has a nonempty fallback for a status-only denial`); + + let validation = ApiError.create({status: 422, fieldName: 'name', detail: 'already used', code: 'NotUnique'}); + let validationText = Errors.stringify(validation, intl); + assert.ok(validationText.startsWith(lookup('resourceSaveError.validation')), + `${locale} begins validation errors in the selected language`); + assert.ok(validationText.includes('name: already used'), 'the API field and detail remain visible'); + assert.strictEqual(Errors.stringify({status: 422, xhr: {responseJSON: {fieldName: 'name', detail: 'too long'}}}, intl), + `${lookup('resourceSaveError.validation')} name: too long`, 'nested API validation details remain visible'); + assert.strictEqual(Errors.stringify({}, intl), lookup('resourceSaveError.failed'), + `${locale} never displays a blank error for an unknown failed save`); + } + + let legacy = ApiError.create({status: 422, fieldName: 'name', detail: 'already used'}); + assert.ok(Errors.stringify(legacy).startsWith('Validation failed in API:'), + 'existing callers without intl keep their original formatting'); +}); diff --git a/tests/unit/utils/multi-stats-test.js b/tests/unit/utils/multi-stats-test.js new file mode 100644 index 0000000000..59e528eb1c --- /dev/null +++ b/tests/unit/utils/multi-stats-test.js @@ -0,0 +1,152 @@ +import { module, test } from 'qunit'; +import { run } from '@ember/runloop'; +import EmberObject from '@ember/object'; + +import MultiStatsSocket from 'ui/utils/multi-stats'; +import Socket from 'ui/utils/socket'; + +module('Unit | Utility | multi stats'); + +test('a disconnected container host does not request a stats token', function(assert) { + let requests = 0; + const resource = EmberObject.create({ + state: 'running', + healthState: null, + hostId: '1h18', + primaryHost: EmberObject.create({state: 'disconnected', agentState: 'disconnected'}), + hasLink() { requests++; return true; }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + + assert.false(stats.get('available'), 'a running container on an offline host has no live stats'); + assert.equal(requests, 0, 'no request is sent to the offline host'); + run(() => stats.close()); +}); + +test('a rejected stats link settles without an unhandled rejection or retry storm', async function(assert) { + let requests = 0; + const resource = EmberObject.create({ + state: 'running', + healthState: null, + hasLink() { return true; }, + followLink() { + requests++; + return Promise.reject({status: 503}); + }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + await stats.get('connectPending'); + + assert.equal(requests, 1, 'one request was attempted'); + assert.true(stats.get('connectError'), 'the UI can show an unavailable state'); + assert.false(stats.get('loading'), 'the spinner does not remain indefinitely'); + assert.notStrictEqual(stats.get('retryTimer'), null, 'at most one delayed retry is scheduled'); + const firstRetry = stats.get('retryTimer'); + run(() => stats.statsUnavailable()); + assert.strictEqual(stats.get('retryTimer'), firstRetry, 'a repeated failure does not schedule another retry'); + + run(() => stats.close()); + assert.notOk(stats.get('retryTimer'), 'closing cancels the retry'); +}); + +test('a synchronous stats-link failure is converted to an unavailable state', async function(assert) { + const resource = EmberObject.create({ + state: 'running', + hasLink() { return true; }, + followLink() { throw new Error('offline'); }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + await stats.get('connectPending'); + + assert.true(stats.get('connectError'), 'a synchronous transport error is handled'); + assert.false(stats.get('loading'), 'the spinner stops'); + run(() => stats.close()); +}); + +test('a socket connect failure releases the old socket before retry', async function(assert) { + const originalCreate = Socket.create; + let attempts = 0; + let disconnects = 0; + Socket.create = function() { + return { + on() {}, + connect() { attempts++; throw new Error('socket unavailable'); }, + disconnect() { disconnects++; }, + }; + }; + let stats; + try { + const resource = EmberObject.create({ + state: 'running', + hasLink() { return true; }, + followLink() { return Promise.resolve(EmberObject.create({url: 'ws://qa.invalid/stats', token: 'test'})); }, + }); + stats = run(() => MultiStatsSocket.create({resource})); + await stats.get('connectPending'); + assert.strictEqual(stats.get('socket'), null, 'failed socket cannot block the next attempt'); + assert.strictEqual(disconnects, 1, 'partially started socket is cleaned up'); + assert.notOk(stats.get('loading'), 'the chart shows unavailable instead of spinning'); + + await run(() => stats.connect()); + assert.strictEqual(attempts, 2, 'a later retry can build a fresh socket'); + assert.strictEqual(disconnects, 2, 'second failed socket is also released'); + assert.strictEqual(stats.get('socket'), null); + } finally { + if (stats) run(() => stats.close()); + Socket.create = originalCreate; + } +}); + +test('missing stats link stops the spinner without a retry loop', function(assert) { + let requests = 0; + const resource = EmberObject.create({ + state: 'running', + hasLink() { return false; }, + followLink() { requests++; }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + + assert.true(stats.get('connectError'), 'missing link has an unavailable state'); + assert.strictEqual(stats.get('connectErrorStatus'), 404, 'missing link is identified'); + assert.false(stats.get('loading'), 'missing link cannot leave the spinner running'); + assert.notOk(stats.get('retryTimer'), 'an absent capability is not polled'); + assert.strictEqual(requests, 0, 'no URL request is attempted'); + run(() => stats.close()); +}); + +test('401, 403, and 404 stats failures keep their status without retrying', async function(assert) { + for (const status of [401, 403, 404]) { + let requests = 0; + const resource = EmberObject.create({ + state: 'running', + hasLink() { return true; }, + followLink() { requests++; return Promise.reject({status}); }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + await stats.get('connectPending'); + + assert.strictEqual(requests, 1, `${status} attempts once`); + assert.strictEqual(stats.get('connectErrorStatus'), status, `${status} stays diagnostic`); + assert.false(stats.get('loading'), `${status} hides the spinner`); + assert.notOk(stats.get('retryTimer'), `${status} is not retried`); + run(() => stats.close()); + } +}); + +test('a late rejected stats link cannot restart a closed chart', async function(assert) { + let rejectLink; + const link = new Promise((resolve, reject) => { rejectLink = reject; }); + const resource = EmberObject.create({ + state: 'running', + hasLink() { return true; }, + followLink() { return link; }, + }); + const stats = run(() => MultiStatsSocket.create({resource})); + const pending = stats.get('connectPending'); + run(() => stats.close()); + rejectLink({status: 503}); + await pending; + + assert.false(stats.get('connectError'), 'the stale response does not replace the closed state'); + assert.notOk(stats.get('retryTimer'), 'no retry is scheduled after close'); +}); diff --git a/translations/de-de.yaml b/translations/de-de.yaml index 668bba630f..59d081dffe 100644 --- a/translations/de-de.yaml +++ b/translations/de-de.yaml @@ -3611,6 +3611,7 @@ projectUpgrade: {appName} {appVersion} enthält Änderungen, die ein Upgrade der vorhandenen Systemdienste und Load Balancer-Container erfordern. notOwner: Nur ein Besitzer dieser Umgebung oder ein Administrator kann das Upgrade durchführen. + notAvailable: Diese Umgebung kann derzeit nicht aktualisiert werden. actionButton: Jetzt upgraden banner: Umgebung wird aktualisiert... registryRow: diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 885066ee17..25863dbeb4 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1005,6 +1005,12 @@ resourceLoadError: accountsFailed: Accounts cannot be loaded right now because the server cannot process the request. Try again later. accountSecurityUnavailable: Account security settings cannot be loaded. The account may no longer exist, or you may not have permission to view it. Refresh the page or contact an administrator. accountSecurityFailed: Account security settings cannot be loaded right now because the server cannot process the request. Try again later. +resourceSaveError: + scaleFailed: The service scale could not be updated. + unavailable: The save could not be completed. The resource may be unavailable, or you may not have permission. Refresh to check what was saved before retrying. + actionUnavailable: The action could not be completed. The resource may be unavailable, or you may not have permission. Refresh to check its current state before trying again. + validation: The server rejected the changes. Check the fields and try again. + failed: The save could not be completed. Refresh to check what was saved before retrying. haPage: header: High Availability @@ -2929,7 +2935,10 @@ identityBlock: infoMultiStats: connecting: Connecting... - utilizationStats: Utilization stats are only available while active/running. + utilizationStats: Monitoring data is temporarily unavailable. Check the resource and host, then try again. + authError: The monitoring session could not be verified. Refresh this page; if the problem persists, sign in again. + permissionError: You do not have permission to view this resource's monitoring data. + notFound: Monitoring data is not available for this resource. time: now: Now minutesSecondsAgo: "{minutes} min, {seconds} sec ago" @@ -3745,6 +3754,10 @@ newBalancer: needsCertificate: "A certificate is required because there are SSL/TLS port rules" newCatalog: + permissionDenied: You do not have permission to create a stack in this environment. + projectChanged: The environment changed while this form was open. Open the stack form again in the intended environment. + templateUnavailable: This catalog template does not exist or you do not have permission to use it. Refresh the page or contact an administrator. + upgradeUnavailable: This stack cannot be upgraded with your current permissions. Refresh the page and check the stack state before trying again. version: prompt: Choose a version... default: The default at the time (currently {version}) @@ -3932,6 +3945,7 @@ projectUpgrade: header: Upgrade Environment detail: "{appName} {appVersion} includes changes that require upgrading the existing system services and load balancer containers." notOwner: "Only an owner of this environment or an administrator can perform the upgrade." + notAvailable: "This environment cannot be upgraded right now." actionButton: Upgrade Now banner: Upgrading Environment... diff --git a/translations/fa-ir.yaml b/translations/fa-ir.yaml index 66b1109bed..9897aa7b7f 100644 --- a/translations/fa-ir.yaml +++ b/translations/fa-ir.yaml @@ -3508,6 +3508,7 @@ projectUpgrade: header: ارتقاء محیط detail: '{appName} {appVersion} شامل تغییراتی است که نیاز به ارتقاء سرویس‌های سیستم موجود و کانتینرهای متعادل کننده بار دارد.' notOwner: فقط یک مالک این محیط یا یک مدیر می تواند ارتقا را انجام دهد. + notAvailable: در حال حاضر نمی‌توان این محیط را ارتقا داد. actionButton: اکنون ارتقا دهید banner: ارتقاء محیط ... registryRow: diff --git a/translations/fil-ph.yaml b/translations/fil-ph.yaml index 253589b084..ba2abfcc5f 100644 --- a/translations/fil-ph.yaml +++ b/translations/fil-ph.yaml @@ -3563,6 +3563,7 @@ projectUpgrade: Kasama sa {appName} {appVersion} ang mga pagbabago na nangangailangan ng pag-upgrade sa mga kasalukuyang serbisyo ng system at mga container ng load balancer. notOwner: Tanging isang may-ari ng environment na ito o isang administrator ang maaaring magsagawa ng pag-upgrade. + notAvailable: Hindi maaaring i-upgrade ang kapaligirang ito sa ngayon. actionButton: Mag-upgrade Ngayon banner: Ina-upgrade ang Kapaligiran... registryRow: diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 8958c493bf..512d6b44cc 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -3560,6 +3560,7 @@ projectUpgrade: {appName} {appVersion} inclut des modifications qui nécessitent la mise à niveau des services système existants et des conteneurs de l’équilibreur de charge. notOwner: Seul un propriétaire de cet environnement ou un administrateur peut effectuer la mise à niveau. + notAvailable: Cet environnement ne peut pas être mis à niveau pour le moment. actionButton: Mettre à jour Maintenant banner: Mise à jour de l'Environnement ... registryRow: diff --git a/translations/hu-hu.yaml b/translations/hu-hu.yaml index 9f4ede914d..122a198783 100644 --- a/translations/hu-hu.yaml +++ b/translations/hu-hu.yaml @@ -3579,6 +3579,7 @@ projectUpgrade: A {appName} {appVersion} olyan változtatásokat tartalmaz, amelyek a meglévő rendszerszolgáltatások és a terheléselosztó tárolók frissítését igénylik. notOwner: Csak ennek a környezetnek a tulajdonosa vagy rendszergazdája hajthatja végre a frissítést. + notAvailable: Ez a környezet jelenleg nem frissíthető. actionButton: Frissítsen most banner: Környezet frissítése... registryRow: diff --git a/translations/ja-jp.yaml b/translations/ja-jp.yaml index 4fae7468de..320374702d 100644 --- a/translations/ja-jp.yaml +++ b/translations/ja-jp.yaml @@ -79,6 +79,9 @@ accountsPage: noName: なし new: header: アカウントを追加 + error: + usernameRequired: ログイン用ユーザー名を入力してください。 + passwordRequired: パスワードを入力してください。 form: username: labelText: ログイン ユーザー名 @@ -258,6 +261,148 @@ auditLogsPage: first: 最初のページ next: 次のページ authPage: + mfa: + navigation: 多要素認証 + header: 多要素認証 + account: アカウント + manageAccounts: システムのログインセキュリティ + manageAccount: '{account} の多要素認証を管理' + managedAccountWarning: 別のアカウントを管理しています。セキュリティ上、管理者は認証要素の確認と削除はできますが、アカウント所有者に代わって新しい認証要素の登録、復旧用メールアドレスの確認、リカバリーコードの取得はできません。 + accountManagement: + header: アカウントのセキュリティ管理 + help: アカウントを選択して、ログインに使用する認証要素を確認または削除します。この選択によって、上のシステム全体のポリシーやメール送信設定は変更されません。 + sessionRevoked: このセキュリティ変更に伴い、このアカウントの有効なセッションを無効にしました。続行するには再度ログインしてください。 + signInAgain: 再度ログイン + selfService: + navigation: ログインセキュリティ + header: 自分のログインセキュリティ + help: 自分の認証要素とアカウント復旧方法を登録・管理します。セキュリティ設定を変更すると、再度ログインが必要になる場合があります。 + manageMine: 自分の多要素認証を管理 + summary: + totp: 認証アプリ + passkeys: パスキー + recoveryCodes: リカバリーコード + policy: 登録ポリシー + required: 必須 + optional: 任意 + recoveryCodes: + header: リカバリーコード + oneTimeHelp: 使い捨てのコードを今すぐ保存してください。後から再表示することはできません。 + copy: コードをコピー + saved: コードを保存しました + help: 登録済みの別の認証方法を使えない場合、リカバリーコードで緊急ログインできます。 + regenerate: 新しいリカバリーコードを生成 + confirm: 既存のリカバリーコードは使えなくなります。新しいコードを生成しますか? + requiresFactor: リカバリーコードを生成する前に、認証アプリまたはパスキーを登録してください。 + factor: + header: 登録済みの認証要素 + type: 種類 + label: 名前 + created: 登録日時 + lastUsed: 最終使用日時 + none: このアカウントには認証要素が登録されていません。 + types: + totp: 認証アプリ + passkey: パスキー + confirmRevoke: '「{label}」を削除しますか?' + confirmRevokeAll: このアカウントの認証要素と復旧方法をすべて削除しますか? + totp: + header: 認証アプリ + scanHelp: 認証アプリでこの QR コードを読み取るか、シークレットを手動で入力し、現在の 6 桁のコードで確認してください。 + qrAlt: 認証アプリ登録用の QR コード + manualSecret: 手動設定用シークレット + defaultLabel: 認証アプリ + code: 6 桁のコード + confirm: 確認して登録 + help: Google Authenticator や Microsoft Authenticator など、RFC 6238 に対応するアプリを登録できます。 + add: 認証アプリを登録 + openAuthenticator: 認証アプリで開く + passkey: + header: パスキー + help: Windows Hello、スマートフォン、ハードウェアセキュリティキーを登録できます。localhost を除き、パスキーには HTTPS が必要です。 + defaultLabel: パスキー + add: パスキーを登録 + notConfigured: パスキーを登録する前に、HTTPS オリジンとリライングパーティー ID を設定してください。 + secureContextRequired: このブラウザーでパスキーを登録するには HTTPS が必要です。ただし、管理画面を localhost で開いている場合は除きます。 + email: + header: 自分の復旧用メールアドレス + help: このアカウントの復旧先としてのみ使うメールアドレスを確認します。このアドレスは多要素認証の要素にはなりません。 + systemManaged: メール送信サービスはシステム管理者が設定し、全アカウントで共有します。アカウントには SMTP サーバー、送信元アドレス、パスワードは保存されません。 + verified: '確認済みの復旧用アドレス:{email}' + revoke: 復旧用メールアドレスを削除 + sent: '6 桁の確認コードを {email} に送信しました。' + code: 6 桁の確認コード + confirm: アドレスを確認 + placeholder: name@example.com + verify: 確認コードを送信 + confirmRevoke: 確認済みの復旧用メールアドレスを削除しますか? + notConfigured: システム管理者が SMTP のメール送信を設定するまで、メールによるアカウント復旧は利用できません。 + securityConfirmation: + header: このセキュリティ変更を確認 + help: 登録済みの認証要素を 1 つ使用してください。この短時間有効な確認は、1 回のセキュリティ操作にのみ使用できます。 + secureContextRequired: この接続では HTTPS または localhost が必要なため、パスキーを使用できません。表示されている別の方法を使うか、HTTPS で接続し直してください。 + confirm: 確認して続行 + error: セキュリティ確認を完了できませんでした。 + settings: + header: システム全体のログインセキュリティ + scopeHelp: これらの設定は、この PastureStack にあるすべてのアカウントに適用され、システム管理者のみが変更できます。 + localRecoveryReady: ローカル管理者の緊急ログインは維持されています。シングルサインオンや認証サービスに接続できない場合も利用できますが、プラットフォームの多要素認証が必要です。 + localRecoveryBlocked: 確認済みのローカル管理者復旧経路がないため、外部認証を安全に使用できません。 + enforcement: 登録要件 + optional: すべてのアカウントで任意 + requiredAdmins: システム管理者に必須 + requiredAll: すべての対話型アカウントに必須 + passkeyLimit: アカウントごとのパスキー登録上限 + issuer: 認証アプリに表示する発行者名 + webAuthn: パスキー設定 + rpName: リライングパーティーの表示名 + rpId: リライングパーティー ID + rpIdPlaceholder: console.example.com + origin: 管理画面の正確なオリジン + originPlaceholder: https://console.example.com + webAuthnHelp: リライングパーティー ID は管理画面のホスト名と一致する必要があり、公開サフィックスは指定できません。本番環境では正確な HTTPS オリジンを使用してください。HTTP が許可されるのは localhost のみです。 + passkeyCounterPolicy: パスキーのカウンター処理 + passkeyCounterRiskAware: 同期されたパスキーを許可し、カウンターの異常を記録 + passkeyCounterStrict: パスキーのカウンターが増加しない場合は必ず拒否 + passkeyCounterHelp: リスクを考慮するモードでは、ユーザー確認済みの同期されたパスキーについて、共有カウンターが増加しなくても受け入れ、異常を記録します。同期されていない認証情報は引き続き拒否します。 + verificationProtection: 確認試行の保護 + maximumFailedAttempts: 一時ロックまでに許容する失敗回数 + lockoutSeconds: 一時ロックの継続時間(秒) + securityConfirmationTtlSeconds: セキュリティ確認の有効期間(秒) + verificationProtectionHelp: 失敗回数はブラウザーのチャレンジ単位ではなく、アカウント単位で記録されます。ログインをやり直しても回数はリセットされません。 + federatedMfa: シングルサインオン後の多要素認証の責任範囲 + federatedMfaMode: OIDC ログイン後の多要素認証方法 + federatedPlatform: 常に PastureStack で再確認 + federatedTrustedClaims: 確認済みの OIDC 多要素認証クレームを信頼 + maximumFederatedAuthenticationAgeSeconds: 上流の多要素認証の最大経過時間(秒) + trustedAuthenticationMethods: 信頼する amr 値(カンマ区切り) + trustedAuthenticationMethodsPlaceholder: mfa, otp, hwk, webauthn + trustedAuthenticationContexts: 信頼する acr 値(カンマ区切り、省略可) + federatedMfaWarning: ID プロバイダーが amr、acr、auth_time に確実に署名する場合にのみ OIDC クレームを信頼してください。ローカル管理者の緊急ログインには、常に PastureStack の多要素認証が必要です。 + smtp: システム共通のメール送信(SMTP) + smtpHelp: メール送信サービスはシステム全体で一度だけ設定します。各アカウントには、本人が確認した復旧先メールアドレスのみを保存します。 + smtpEnabled: アカウント復旧用の共通 SMTP 送信を有効にする + smtpHost: SMTP サーバー + smtpPort: ポート + smtpUsername: ユーザー名 + smtpPassword: パスワード + smtpClearPassword: 保存済みの SMTP パスワードを削除 + smtpFrom: 送信元アドレス + startTls: STARTTLS で接続を暗号化 + implicitTls: 暗黙的 TLS を使用 + codeTtl: コードの有効期間(秒) + testRecipient: テスト送信先 + saveAndTest: 保存してテストメールを送信 + securityEmailLocale: セキュリティメールの言語 + localeTraditionalChinese: 繁体字中国語(台湾) + localeEnglish: 英語 + danger: + header: 認証・復旧手段をすべて削除 + help: 選択したアカウントの認証アプリ、パスキー、リカバリーコード、確認済みの復旧用メールアドレスをすべて削除します。 + revokeAll: 認証要素と復旧方法をすべて削除 + error: + generic: 多要素認証のリクエストを完了できませんでした。 + secureContext: localhost を除き、パスキーには HTTPS が必要です。 oidc: defaultProviderName: OpenID Connect status: @@ -766,6 +911,20 @@ failWhalePage: 再度試してみるために以下を実施してください
再読み込みまたは logoutButton: ログアウト +resourceLoadError: + projectTemplateUnavailable: この環境テンプレートは存在しないか、編集する権限がありません。ページを再読み込みするか、管理者にお問い合わせください。 + stackUnavailable: このアプリケーションスタックは存在しないか、表示する権限がありません。ページを再読み込みするか、管理者にお問い合わせください。 + stackFailed: サーバーが現在リクエストを処理できないため、このアプリケーションスタックを読み込めません。しばらくしてからもう一度お試しください。 + accountsUnavailable: アカウントを読み込めません。アカウントが存在しないか、表示する権限がない可能性があります。ページを再読み込みするか、管理者にお問い合わせください。 + accountsFailed: サーバーが現在リクエストを処理できないため、アカウントを読み込めません。しばらくしてからもう一度お試しください。 + accountSecurityUnavailable: アカウントのセキュリティ設定を読み込めません。アカウントが存在しないか、表示する権限がない可能性があります。ページを再読み込みするか、管理者にお問い合わせください。 + accountSecurityFailed: サーバーが現在リクエストを処理できないため、アカウントのセキュリティ設定を読み込めません。しばらくしてからもう一度お試しください。 +resourceSaveError: + scaleFailed: サービスの数を更新できませんでした。 + unavailable: 保存を完了できませんでした。リソースを利用できないか、権限がない可能性があります。再試行する前にページを再読み込みして、保存済みの内容を確認してください。 + actionUnavailable: 操作を完了できませんでした。リソースを利用できないか、権限がない可能性があります。再試行する前にページを再読み込みして、現在の状態を確認してください。 + validation: サーバーが変更を受け付けませんでした。入力項目を確認して、もう一度お試しください。 + failed: 保存を完了できませんでした。再試行する前にページを再読み込みして、保存済みの内容を確認してください。 haPage: header: 高可用性 setup: @@ -960,6 +1119,62 @@ loginPage: greeting: '
ようこそ {appName} へ!' githubMessage: '{appName} はアカウントやチームの管理に GitHub を利用しています。 ログインしてあなたの GitHub アカウント情報に読み込み権限でアクセスするために以下のボタンを押して下さい。' oidcMessage: '{appName} はシングルサインオンに {providerName} を使用します。設定済みの ID プロバイダーに進んでログインしてください。' + localRecovery: + header: ローカル管理者による復旧 + help: 外部の ID プロバイダーを利用できない場合にのみ使用してください。ここからログインできるのは、有効なシステム管理者だけです。 + action: ローカル管理者アカウントでログイン + back: シングルサインオンに戻る + mfa: + header: 本人確認 + enrollmentRequired: セッションを開始するには、このアカウントで多要素認証を登録する必要があります。 + methods: + totp: 認証アプリ + totpEnrollment: 認証アプリを設定 + webauthn: パスキー + webauthnEnrollment: パスキーを設定 + recoveryCode: リカバリーコード + emailRecovery: メールでアカウントを復旧 + recoveryOptions: + show: 認証アプリやパスキーを使用できませんか? + header: アカウントの復旧 + help: 登録済みの認証方法を使用できない場合にのみ、これらの復旧方法を使用してください。 + back: 通常の本人確認に戻る + totp: + enrollHelp: 設定用 URI を認証アプリに追加するか、シークレットを手動で入力してから、現在の 6 桁のコードを入力してください。 + secret: 設定用シークレット + code: 6 桁のコード + placeholder: '000000' + qrAlt: 認証アプリの設定用 QR コード + verify: 確認 + passkey: + help: Windows Hello、スマートフォン、ハードウェアセキュリティキーに登録済みのパスキーを使用します。 + action: パスキーを使用 + enrollHelp: Windows Hello、スマートフォン、ハードウェアセキュリティキーでパスキーを作成します。 + enrollAction: パスキーを設定 + secureContextRequired: パスキーには HTTPS または localhost が必要です。この接続では、認証アプリまたはアカウント復旧方法を使用してください。 + recoveryCode: + help: 未使用のリカバリーコードを 1 つ入力してください。 + placeholder: リカバリーコード + emailRecovery: + warning: メールによる復旧では、登録済みの認証要素と有効なセッションがリセットされます。メールは多要素認証の要素としては使用されません。 + destination: '{email} に復旧コードを送信' + placeholder: 6 桁の復旧コード + complete: アカウントを復旧 + send: 復旧コードを送信 + cancel: ログインを中止 + recoveryCodes: + header: リカバリーコードを保存 + help: 使い捨てのコードを安全な場所に保存してください。後から再表示することはできません。 + copy: コードをコピー + saved: コードを保存しました + error: + invalid: 確認に失敗しました。入力内容を確認して、もう一度お試しください。 + secureContext: localhost を除き、パスキーには HTTPS が必要です。 + locked: 確認の失敗回数が上限を超えました。一時ロックが解除されてから、もう一度お試しください。 + factorRequired: このセキュリティ変更を確認する前に、認証アプリまたはパスキーを登録してください。 + administratorFactorRequired: 別のアカウントを管理する前に、管理者アカウントに認証要素を登録してください。 + passkeyCounter: このパスキーを安全に確認できませんでした。別の登録済み認証方法をお試しください。 + passkeyLimit: このアカウントは、設定されたパスキーの登録上限に達しています。 shibbolethMessage: '{appName} はアカウントの管理にシボレスを利用しています。 設定済みのシボレス IDP にログインするには以下のボタンを押して下さい。' shibbolethError: '401': 認証に失敗しました @@ -1651,6 +1866,10 @@ devicePermissions: mknod: ムノッド editAccount: title: アカウントを編集 + error: + currentPasswordRequired: 現在のパスワードを入力してください。 + newPasswordsMismatch: 新しいパスワードが一致しません。 + currentPasswordIncorrect: 現在のパスワードが正しくありません。 form: name: label: 名前 @@ -2556,7 +2775,10 @@ identityBlock: systemService: システムサービス infoMultiStats: connecting: 接続中... - utilizationStats: 利用率の統計はアクティブ/起動中の場合のみ利用できます。 + utilizationStats: 監視データを一時的に取得できません。リソースとホストの状態を確認してから、もう一度お試しください。 + authError: 監視セッションを確認できません。ページを再読み込みし、解決しない場合は再度ログインしてください。 + permissionError: このリソースの監視データを表示する権限がありません。 + notFound: このリソースの監視データは利用できません。 time: now: 現在 minutesSecondsAgo: '{minutes}分{seconds}秒前' @@ -3346,6 +3568,10 @@ newBalancer: noTarget: 各ルールには対象が必要です needsCertificate: SSL/TLS ポートルールであるため証明書が必要です newCatalog: + permissionDenied: この環境でスタックを作成する権限がありません。 + projectChanged: このフォームを開いた後に環境が切り替わりました。対象の環境でスタック作成フォームを開き直してください。 + templateUnavailable: このカタログテンプレートは存在しないか、使用する権限がありません。ページを再読み込みするか、管理者にお問い合わせください。 + upgradeUnavailable: このスタックは現在アップグレードできないか、権限がありません。ページを更新して状態を確認してください。 version: prompt: バージョンを選択してください... default: '当時のデフォルト(現在は{version})' @@ -3523,6 +3749,7 @@ projectUpgrade: header: 環境をアップグレード detail: '{appName} {appVersion} には、既存のシステムサービスやロードバランサーコンテナをアップグレードする必要がある変更が含まれています。' notOwner: この環境のオーナーまたは管理者だけがアップグレードを実行できます。 + notAvailable: この環境は現在アップグレードできません。 actionButton: 今すぐアップグレード banner: 環境をアップグレード中... registryRow: @@ -3726,6 +3953,8 @@ viewEditDescription: viewEditProject: nativeEngine: ネイティブエンジン error: + memberAlreadyListed: このメンバーはすでに一覧に追加されています。 + ownerRequired: 環境のオーナーを 1 人以上追加してください。 projectUnavailable: この環境は存在しないか、表示する権限がありません。ページを再読み込みするか、管理者にお問い合わせください。 membersUnavailable: 環境のメンバーを読み込めませんでした。この環境が存在しなくなったか、アクセス権がなくなった可能性があります。ページを再読み込みするか、管理者にお問い合わせください。 projectNotSaved: 環境の設定は保存されませんでした。この環境が存在しなくなったか、編集権限がない可能性があります。ページを再読み込みして確認してください。 diff --git a/translations/ko-kr.yaml b/translations/ko-kr.yaml index 684fb540db..9b4e5d9e14 100644 --- a/translations/ko-kr.yaml +++ b/translations/ko-kr.yaml @@ -3423,6 +3423,7 @@ projectUpgrade: header: 환경 업그레이드 detail: '{appName} {appVersion}에는 기존 시스템 서비스 및 로드 밸런서 컨테이너를 업그레이드해야 하는 변경 사항이 포함되어 있습니다.' notOwner: 이 환경의 소유자 또는 관리자만 업그레이드를 수행할 수 있습니다. + notAvailable: 현재 이 환경을 업그레이드할 수 없습니다. actionButton: 지금 업그레이드 banner: 환경 업그레이드 중... registryRow: diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index f90472a555..7d5e902eb6 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -3562,6 +3562,7 @@ projectUpgrade: {appName} {appVersion} inclui alterações que exigem a atualização dos serviços de sistema existentes e dos contêineres do balanceador de carga. notOwner: Somente um proprietário deste ambiente ou um administrador pode realizar a atualização. + notAvailable: Este ambiente não pode ser atualizado agora. actionButton: Atualize agora banner: Atualizando o ambiente... registryRow: diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 99afb4dc1f..872f862788 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -3589,6 +3589,7 @@ projectUpgrade: {appName} {appVersion} включает изменения, требующие обновления существующих системных служб и контейнеров балансировки нагрузки. notOwner: Обновление может выполнить только владелец этой среды или администратор. + notAvailable: Сейчас эту среду нельзя обновить. actionButton: Обновите сейчас banner: Обновление среды... registryRow: diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 6367e69896..a47809bc14 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -3613,6 +3613,7 @@ projectUpgrade: {appName} {appVersion} містить зміни, які потребують оновлення наявних системних служб і контейнерів балансувальника навантаження. notOwner: Лише власник цього середовища або адміністратор може виконати оновлення. + notAvailable: Зараз це середовище неможливо оновити. actionButton: Оновити зараз banner: Оновлення середовища... registryRow: diff --git a/translations/zh-hans.yaml b/translations/zh-hans.yaml index 598ab7a3ef..1ba63118ce 100644 --- a/translations/zh-hans.yaml +++ b/translations/zh-hans.yaml @@ -3457,6 +3457,7 @@ projectUpgrade: header: 升级环境 detail: '{appName} {appVersion} 包含更新需要升级已有的系统服务以及负载均衡器容器。' notOwner: 只有环境的所有者或者管理员能够进行升级操作。 + notAvailable: 此环境目前无法升级。 actionButton: 立刻升级 banner: 环境升级中... registryRow: diff --git a/translations/zh-tw.yaml b/translations/zh-tw.yaml index 96a1dd87cc..213fe77338 100644 --- a/translations/zh-tw.yaml +++ b/translations/zh-tw.yaml @@ -975,6 +975,12 @@ resourceLoadError: accountsFailed: 暫時無法載入帳號資料,伺服器目前無法處理。請稍後再試。 accountSecurityUnavailable: 無法載入帳號安全性設定:帳號可能不存在,或您沒有檢視權限。請重新整理或聯絡管理員。 accountSecurityFailed: 暫時無法載入帳號安全性設定,伺服器目前無法處理。請稍後再試。 +resourceSaveError: + scaleFailed: 無法更新服務數量。 + unavailable: 無法完成儲存。資源可能無法使用,或您沒有權限。請先重新整理並確認已儲存的內容,再重試。 + actionUnavailable: 無法完成此操作。資源可能無法使用,或您沒有權限。請先重新整理並確認目前狀態,再重試。 + validation: 伺服器未接受變更。請檢查欄位後重試。 + failed: 無法完成儲存。請先重新整理並確認已儲存的內容,再重試。 haPage: header: 高可用 setup: @@ -2800,7 +2806,10 @@ identityBlock: systemService: 系統服務 infoMultiStats: connecting: 連線中… - utilizationStats: 使用率統計資料僅在活動/執行時可見 + utilizationStats: 暫時無法取得監控資料。請確認資源與主機狀態,稍後再試。 + authError: 無法驗證監控工作階段。請重新整理頁面;若問題持續,請重新登入。 + permissionError: 你沒有權限檢視此資源的監控資料。 + notFound: 此資源目前沒有可用的監控資料。 time: now: 現在 minutesSecondsAgo: '{minutes}分{seconds}秒前' @@ -3584,6 +3593,10 @@ newBalancer: noTarget: 每條規則必須設定目標連接埠 needsCertificate: 設定 SSL/TLS 規則時需要憑證 newCatalog: + permissionDenied: 您沒有權限在此環境建立應用堆疊。 + projectChanged: 此表單開啟後環境已切換。請在目標環境重新開啟建立堆疊表單。 + templateUnavailable: 找不到此應用目錄範本,或您沒有權限使用。請重新整理或聯絡管理員。 + upgradeUnavailable: 目前無法升級此應用堆疊,或您沒有升級權限。請重新整理並確認堆疊狀態後再試。 version: prompt: 選擇版本… default: '目前預設 (目前版本 {version})' @@ -3761,6 +3774,7 @@ projectUpgrade: header: 升級環境 detail: '{appName} {appVersion} 包含更新需要升級已有的系統服務以及負載平衡器容器。' notOwner: 只有環境的所有者或管理員能夠進行升級操作。 + notAvailable: 此環境目前無法升級。 actionButton: 立刻升級 banner: 環境升級中… registryRow: