fix: some actions should be hidden for read-only user (#855)

* fix: some actions limited for virt-viewer

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: based on schema collectionMethods

Signed-off-by: Andy Lee <andy.lee@suse.com>

* fix: conditionally add migrate action to available actions

* fix: update collectionMethods check to use find for case-insensitive matching

* fix: update canEditClusterMembers method to use schema for collectionMethods

* fix: update canCreateImage check to use case-insensitive matching

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-05-07 10:21:26 +08:00 committed by GitHub
parent 032700293c
commit 5cc8b4c301
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 111 additions and 14 deletions

View File

@ -122,7 +122,7 @@ export default {
return this.$store.getters['currentCluster'].isLocal; return this.$store.getters['currentCluster'].isLocal;
}, },
canEditClusterMembers() { canEditClusterMembers() {
return this.normanClusterRTBSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post'); return this.schema?.collectionMethods.find((x) => x.toLowerCase() === 'post');
}, },
}, },
}; };

View File

@ -686,7 +686,7 @@ export function init($plugin, store) {
}, },
resource: NETWORK_ATTACHMENT, resource: NETWORK_ATTACHMENT,
resourceDetail: HCI.NETWORK_ATTACHMENT, resourceDetail: HCI.NETWORK_ATTACHMENT,
resourceEdit: HCI.NETWORK_ATTACHMENT resourceEdit: HCI.NETWORK_ATTACHMENT,
}); });
virtualType({ virtualType({

View File

@ -3,7 +3,6 @@ import { Banner } from '@components/Banner';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import ResourceTable from '@shell/components/ResourceTable'; import ResourceTable from '@shell/components/ResourceTable';
import BadgeState from '@shell/components/formatter/BadgeStateFormatter'; import BadgeState from '@shell/components/formatter/BadgeStateFormatter';
import { NAME, AGE, NAMESPACE, STATE } from '@shell/config/table-headers'; import { NAME, AGE, NAMESPACE, STATE } from '@shell/config/table-headers';
import { NETWORK_ATTACHMENT, SCHEMA } from '@shell/config/types'; import { NETWORK_ATTACHMENT, SCHEMA } from '@shell/config/types';
import { allHash } from '@shell/utils/promise'; import { allHash } from '@shell/utils/promise';

View File

@ -30,11 +30,12 @@ const STATUS_DISPLAY = {
export default class PCIDevice extends SteveModel { export default class PCIDevice extends SteveModel {
get _availableActions() { get _availableActions() {
const out = super._availableActions; const out = super._availableActions;
const canUpdate = !!this.linkFor('update');
out.push( out.push(
{ {
action: 'enablePassthroughBulk', action: 'enablePassthroughBulk',
enabled: !this.isEnabling && !this.isvGPUDevice, enabled: !this.isEnabling && !this.isvGPUDevice && canUpdate,
icon: 'icon icon-fw icon-dot', icon: 'icon icon-fw icon-dot',
label: 'Enable Passthrough', label: 'Enable Passthrough',
bulkable: true, bulkable: true,
@ -43,7 +44,7 @@ export default class PCIDevice extends SteveModel {
}, },
{ {
action: 'disablePassthrough', action: 'disablePassthrough',
enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice, enabled: this.isEnabling && this.claimedByMe && !this.isvGPUDevice && canUpdate,
icon: 'icon icon-fw icon-dot-open', icon: 'icon icon-fw icon-dot-open',
label: 'Disable Passthrough', label: 'Disable Passthrough',
bulkable: true, bulkable: true,

View File

@ -24,7 +24,7 @@ const OBSCURE_NAMESPACE_PREFIX = [
export default class HciNamespace extends namespace { export default class HciNamespace extends namespace {
get _availableActions() { get _availableActions() {
const out = super._availableActions; let out = super._availableActions;
const remove = out.findIndex((a) => a.action === 'promptRemove'); const remove = out.findIndex((a) => a.action === 'promptRemove');
const promptRemove = { const promptRemove = {
@ -53,6 +53,16 @@ export default class HciNamespace extends namespace {
insertAt(out, out.length - 1, promptRemove); insertAt(out, out.length - 1, promptRemove);
insertAt(out, out.length - 5, editQuotaAction); insertAt(out, out.length - 5, editQuotaAction);
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (['move'].includes(action.action)) {
return { ...action, enabled: action.enabled && canUpdate };
}
return action;
});
return out; return out;
} }

View File

@ -5,6 +5,20 @@ import Secret from '@shell/models/secret';
import { NAMESPACE } from '@shell/config/types'; import { NAMESPACE } from '@shell/config/types';
export default class HciSecret extends Secret { export default class HciSecret extends Secret {
get _availableActions() {
let out = super._availableActions;
out = out.map((action) => {
if (['download'].includes(action.action)) {
return { ...action, enabled: !!this.linkFor('update') };
}
return action;
});
return out;
}
// prevent harvester secret detail page be overridden. // prevent harvester secret detail page be overridden.
// See isFullPageOverride in https://github.com/rancher/dashboard/blob/master/shell/components/ResourceDetail/index.vue // See isFullPageOverride in https://github.com/rancher/dashboard/blob/master/shell/components/ResourceDetail/index.vue
get fullDetailPageOverride() { get fullDetailPageOverride() {

View File

@ -106,6 +106,15 @@ export default class HciStorageClass extends StorageClass {
get availableActions() { get availableActions() {
let out = super.availableActions || []; let out = super.availableActions || [];
const canUpdate = !!this.linkFor('update');
out = out.map((action) => {
if (['setDefault', 'setAsDefault', 'resetDefault'].includes(action.action)) {
return { ...action, enabled: canUpdate };
}
return action;
});
if (this.isInternalStorageClass()) { if (this.isInternalStorageClass()) {
out = out.filter((action) => { out = out.filter((action) => {

View File

@ -21,9 +21,10 @@ export default class HciAddonConfig extends HarvesterResource {
out.push(rancherDashboard); out.push(rancherDashboard);
} }
const canUpdate = !!this.linkFor('update');
const toggleAddon = { const toggleAddon = {
action: 'toggleAddon', action: 'toggleAddon',
enabled: true, enabled: canUpdate,
icon: this.spec.enabled ? 'icon icon-pause' : 'icon icon-play', icon: this.spec.enabled ? 'icon icon-pause' : 'icon icon-play',
label: this.spec.enabled ? this.t('generic.disable') : this.t('generic.enable'), label: this.spec.enabled ? this.t('generic.disable') : this.t('generic.enable'),
}; };

View File

@ -3,6 +3,20 @@ import { findBy } from '@shell/utils/array';
import HarvesterResource from './harvester'; import HarvesterResource from './harvester';
export default class HciKeypair extends HarvesterResource { export default class HciKeypair extends HarvesterResource {
get _availableActions() {
let out = super._availableActions;
out = out.map((action) => {
if (['download'].includes(action.action)) {
return { ...action, enabled: !!this.linkFor('update') };
}
return action;
});
return out;
}
get stateDisplay() { get stateDisplay() {
const conditions = get(this, 'status.conditions'); const conditions = get(this, 'status.conditions');
const status = (findBy(conditions, 'type', 'validated') || {}).status ; const status = (findBy(conditions, 'type', 'validated') || {}).status ;

View File

@ -19,16 +19,18 @@ export default class ScheduleVmBackup extends HarvesterResource {
} }
}); });
const canUpdate = !!this.linkFor('update');
return [ return [
{ {
action: 'resumeSchedule', action: 'resumeSchedule',
enabled: ucFirst(this.state) === STATES.suspended.label, enabled: canUpdate && ucFirst(this.state) === STATES.suspended.label,
icon: 'icons icon-play', icon: 'icons icon-play',
label: this.t('harvester.action.resumeSchedule'), label: this.t('harvester.action.resumeSchedule'),
}, },
{ {
action: 'suspendSchedule', action: 'suspendSchedule',
enabled: ucFirst(this.state) === STATES.active.label, enabled: canUpdate && ucFirst(this.state) === STATES.active.label,
icon: 'icons icon-pause', icon: 'icons icon-pause',
label: this.t('harvester.action.suspendSchedule'), label: this.t('harvester.action.suspendSchedule'),
}, },

View File

@ -52,6 +52,7 @@ export default class HciVmImage extends HarvesterResource {
canCreateVM = false; canCreateVM = false;
} }
const canCreateImage = !!this.$getters?.['schemaFor']?.(HCI.IMAGE)?.collectionMethods?.some((method) => method.toLowerCase() === 'post');
const customActions = this.isReady ? [ const customActions = this.isReady ? [
{ {
action: 'createFromImage', action: 'createFromImage',
@ -61,13 +62,13 @@ export default class HciVmImage extends HarvesterResource {
}, },
{ {
action: 'encryptImage', action: 'encryptImage',
enabled: this.volumeEncryptionFeatureEnabled && !this.isEncrypted, enabled: this.volumeEncryptionFeatureEnabled && !this.isEncrypted && canCreateImage,
icon: 'icon icon-lock', icon: 'icon icon-lock',
label: this.t('harvester.action.encryptImage'), label: this.t('harvester.action.encryptImage'),
}, },
{ {
action: 'decryptImage', action: 'decryptImage',
enabled: this.volumeEncryptionFeatureEnabled && this.isEncrypted, enabled: this.volumeEncryptionFeatureEnabled && this.isEncrypted && canCreateImage,
icon: 'icon icon-unlock', icon: 'icon icon-unlock',
label: this.t('harvester.action.decryptImage'), label: this.t('harvester.action.decryptImage'),
}, },

View File

@ -130,6 +130,8 @@ export default class VirtVm extends HarvesterResource {
clone.action = 'goToCloneVM'; clone.action = 'goToCloneVM';
} }
const canCreateVMSSchedule = !!this.$getters?.['schemaFor']?.(HCI.SCHEDULE_VM_BACKUP)?.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase()));
return [ return [
{ {
action: 'stopVM', action: 'stopVM',
@ -207,7 +209,7 @@ export default class VirtVm extends HarvesterResource {
}, },
{ {
action: 'createSchedule', action: 'createSchedule',
enabled: this.schedulingVMBackupFeatureEnabled, enabled: canCreateVMSSchedule && this.schedulingVMBackupFeatureEnabled,
icon: 'icon icon-history', icon: 'icon icon-history',
label: this.t('harvester.action.createSchedule') label: this.t('harvester.action.createSchedule')
}, },

View File

@ -0,0 +1,19 @@
import shellProject from '@shell/models/management.cattle.io.project';
// This model controls `Project / Namespace` page in rancher integration mode
// Extend management.cattle.io.project model from shell
export default class Project extends shellProject {
get _availableActions() {
const canUpdate = !!this.linkFor('update');
// disable `Edit Config` action if user does not have update permission.
return super._availableActions.map((action) => {
if (action.action === 'goToEdit') {
return { ...action, enabled: canUpdate };
}
return action;
});
}
}

View File

@ -57,7 +57,11 @@ export default class HciVlanConfig extends HarvesterResource {
get _availableActions() { get _availableActions() {
const out = super._availableActions; const out = super._availableActions;
const canMigrate = !!this.$getters?.['schemaFor']?.(HCI.VLAN_CONFIG)?.collectionMethods?.find((x) => ['post'].includes(x.toLowerCase()));
if (canMigrate) {
insertAt(out, 0, this.migrateAction); insertAt(out, 0, this.migrateAction);
}
return out; return out;
} }

View File

@ -123,5 +123,26 @@ export default {
const clusterId = currentCluster.id; const clusterId = currentCluster.id;
return projectsInAllClusters.filter((project: any) => project.spec.clusterName === clusterId && project.nameDisplay !== 'System'); return projectsInAllClusters.filter((project: any) => project.spec.clusterName === clusterId && project.nameDisplay !== 'System');
},
// Few harvester resources name and REAL resource are different. E.g. HCI.NETWORK_ATTACHMENT page resource is NETWORK_ATTACHMENT.
// Check in config/harvester-cluster.js for more details.
// We need to look up the schema by resource name, and fallback to find using real resource name
schemaFor: (state, getters, rootState, rootGetters) => (type, _fuzzy = false, _allowThrow = true) => {
// follow the same logic as type-map/schemaFor in /dashboard/shell/plugins/dashboard-store/getters.js
const normalizedType = getters.normalizeType(type);
const schemas = state.types['schema'];
const out = schemas?.map?.get(normalizedType);
if (out) return out;
// if not found, use the resource mapping in configureType for a second try
const resourceType = rootGetters['type-map/optionsFor'](type)?.resource;
if (resourceType && resourceType !== type) {
const normalizedResource = getters.normalizeType(resourceType);
return schemas?.map?.get(normalizedResource) || null;
} }
return null;
},
}; };