Migrating cluster membership from ember to vue (clusterRoleTemplateBindings)

rancher/dashboard#2501
This commit is contained in:
Cody Jackson 2021-05-11 14:17:56 -07:00
parent 1b6da12755
commit bbfc4070aa
13 changed files with 492 additions and 30 deletions

View File

@ -1686,6 +1686,33 @@ login:
loginWithLocal: Log in with Local User
useProvider: Use a {provider} user
members:
clusterMembers: Cluster Members
createActionLabel: Add
clusterPermissions:
noDescription: User created - no description
label: Cluster Permissions
description: Controls what access users have to the Cluster
createProjects: Create Projects
manageClusterBackups: Manage Cluster Backups
manageClusterCatalogs: Manage Cluster Catalogs
manageClusterMembers: Manage Cluster Members
manageNodes: Manage Nodes
manageStorage: Manage Storage
viewAllProjects: View All Projects
viewClusterCatalogs: View Cluster Catalogs
viewClusterMembers: View Cluster Members
viewNodes: View Nodes
owner:
label: Owner
description: Owners have full control over the Cluster and all resources inside it.
member:
label: Member
description: Members can manage the resources inside the Cluster but not change the Cluster itself.
custom:
label: Custom
description: Choose individual roles for this user.
monitoring:
accessModes:
many: ReadWriteMany
@ -2500,6 +2527,8 @@ rbac:
fleetworkspace-admin: Admin
fleetworkspace-member: Member
fleetworkspace-readonly: Read-Only
members:
label: Members
roletemplate:
label: Roles
newUserDefault:
@ -4129,6 +4158,11 @@ typeLabel:
one { Cluster Group }
other {Cluster Groups }
}
management.cattle.io.clusterroletemplatebinding: |-
{count, plural,
one { Cluster Member }
other { Cluster Members }
}
fleet.cattle.io.gitrepo: |-
{count, plural,
one { Git Repo }
@ -4215,6 +4249,11 @@ typeLabel:
one { User }
other { Users }
}
namespace: |-
{count, plural,
one { Namespace }
other { Namespaces }
}
group.principal: |-
{count, plural,
one { Group }

View File

@ -317,7 +317,8 @@ export default {
{{ parent.displayName }}:
</nuxt-link>
<span v-else>{{ parent.displayName }}:</span>
<t :k="'resourceDetail.header.' + realMode" :subtype="resourceSubtype" :name="value.nameDisplay" />
<span v-if="value.detailPageHeaderActionOverride && value.detailPageHeaderActionOverride(realMode)">{{ value.detailPageHeaderActionOverride(realMode) }}</span>
<t v-else :k="'resourceDetail.header.' + realMode" :subtype="resourceSubtype" :name="value.nameDisplay" />
<BadgeState v-if="!isCreate && parent.showState" class="masthead-state" :value="value" />
</h1>
</div>

View File

@ -34,7 +34,17 @@ export default {
mode: {
type: String,
default: 'edit',
}
},
descriptionKey: {
type: String,
default: null
},
description: {
type: String,
default: null
},
},
data() {
@ -102,17 +112,27 @@ export default {
:aria-checked="isChecked"
role="radio"
/>
<label
v-if="label"
:class="[ muteLabel ? 'text-muted' : '', 'radio-label']"
v-html="label"
>
<slot name="label">{{ label }}</slot>
</label>
<div class="labeling">
<label
v-if="label"
:class="[ muteLabel ? 'text-muted' : '', 'radio-label', 'm-0']"
v-html="label"
>
<slot name="label">{{ label }}</slot>
</label>
<div v-if="descriptionKey || description" class="radio-button-outer-container-description">
<t v-if="descriptionKey" :k="descriptionKey" />
<template v-else-if="description">
{{ description }}
</template>
</div>
</div>
</label>
</template>
<style lang='scss'>
$fontColor: var(--input-label);
.radio-view {
display: flex;
flex-direction: column;
@ -131,7 +151,7 @@ export default {
.radio-container {
position: relative;
display: inline-flex;
align-items: center;
align-items: flex-start;
margin: 0;
cursor: pointer;
user-select: none;
@ -142,10 +162,6 @@ export default {
cursor: not-allowed
}
.radio-label {
margin: 3px 10px 0px 5px;
}
.radio-custom {
height: 14px;
width: 14px;
@ -155,6 +171,7 @@ export default {
border-radius: 50%;
transition: all 0.3s ease-out;
border: 1.5px solid var(--border);
margin-top: 5px;
&:focus {
outline: none;
@ -186,6 +203,19 @@ export default {
background-color: var(--disabled-bg);
opacity: .25;
}
.radio-button-outer-container-description {
color: $fontColor;
font-size: 11px;
margin-top: 5px;
}
.labeling {
display: inline-flex;
flex-direction: column;
margin: 3px 10px 0px 5px;
}
}
</style>

View File

@ -150,6 +150,7 @@ export default {
:name="name"
:value="value"
:label="option.label"
:description="option.description"
:val="option.value"
:disabled="isDisabled"
:mode="mode"

View File

@ -4,6 +4,7 @@ import {
WORKLOAD, WORKLOAD_TYPES, SERVICE, HPA, NETWORK_POLICY, PV, PVC, STORAGE_CLASS, POD,
RBAC,
MANAGEMENT,
NAMESPACE,
NORMAN,
} from '@/config/types';
@ -27,6 +28,7 @@ export function init(store) {
basicType,
ignoreType,
mapGroup,
mapType,
weightGroup,
weightType,
headers,
@ -40,7 +42,7 @@ export function init(store) {
weight: 3,
showNamespaceFilter: true,
icon: 'compass',
typeStoreMap: { [MANAGEMENT.PROJECT]: 'management' }
typeStoreMap: { [MANAGEMENT.PROJECT]: 'management', [MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING]: 'management' }
});
basicType(['cluster-dashboard', 'cluster-tools']);
@ -77,6 +79,7 @@ export function init(store) {
RBAC.CLUSTER_ROLE,
RBAC.ROLE_BINDING,
RBAC.CLUSTER_ROLE_BINDING,
'cluster-members'
], 'rbac');
weightGroup('cluster', 99, true);
@ -124,9 +127,12 @@ export function init(store) {
mapGroup(/^(.*\.)?cluster\.x-k8s\.io$/, 'Cluster Provisioning');
mapGroup(/^(aks|eks|gke|rke|rke-machine-config|provisioning)\.cattle\.io$/, 'Cluster Provisioning');
mapType(MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING, store.getters['i18n/t'](`typeLabel.${ MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING }`, { count: 2 }));
configureType(NODE, { isCreatable: false, isEditable: false });
configureType(WORKLOAD_TYPES.JOB, { isEditable: false, match: WORKLOAD_TYPES.JOB });
configureType(PVC, { isEditable: false });
configureType(MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING, { isEditable: false });
configureType('workload', {
displayName: 'Workload',
@ -200,7 +206,7 @@ export function init(store) {
]);
virtualType({
label: 'Cluster Dashboard',
label: store.getters['i18n/t']('clusterIndexPage.header'),
group: 'Root',
namespaced: false,
name: 'cluster-dashboard',
@ -211,7 +217,18 @@ export function init(store) {
});
virtualType({
label: 'Overview',
label: store.getters['i18n/t']('members.clusterMembers'),
group: 'rbac',
namespaced: false,
name: 'cluster-members',
icon: 'globe',
weight: 100,
route: { name: 'c-cluster-explorer-members' },
exact: true,
});
virtualType({
label: store.getters['i18n/t']('generic.overview'),
group: 'Workload',
namespaced: true,
name: 'workload',
@ -226,7 +243,7 @@ export function init(store) {
});
virtualType({
label: 'Projects/Namespaces',
label: store.getters['i18n/t']('projectNamespaces.label'),
group: 'cluster',
icon: 'globe',
namespaced: false,
@ -238,7 +255,7 @@ export function init(store) {
});
virtualType({
label: 'Namespaces',
label: store.getters['i18n/t'](`typeLabel.${ NAMESPACE }`, { count: 2 }),
group: 'cluster',
icon: 'globe',
namespaced: false,

View File

@ -177,6 +177,14 @@ export const RAM = {
width: 120,
};
export const PRINCIPAL = {
name: 'principal',
labelKey: 'tableHeaders.name',
sort: 'principal.loginName',
value: 'userPrincipalName',
formatter: 'Principal',
};
export const PODS = {
name: 'pods',
labelKey: 'tableHeaders.pods',
@ -789,6 +797,12 @@ export const RESTART = {
width: 75,
};
export const ROLE = {
name: 'role',
value: 'roleDisplay',
labelKey: 'tableHeaders.role',
};
export const FEATURE_DESCRIPTION = {
name: 'description',
labelKey: 'tableHeaders.description',

View File

@ -12,14 +12,15 @@ export const STEVE = {
// Auth (via Norman)
// Base: /v3
export const NORMAN = {
AUTH_CONFIG: 'authconfig',
ETCD_BACKUP: 'etcdbackup',
CLUSTER_TOKEN: 'clusterregistrationtoken',
GROUP: 'group',
PRINCIPAL: 'principal',
SPOOFED: { GROUP_PRINCIPAL: 'group.principal' },
TOKEN: 'token',
USER: 'user',
AUTH_CONFIG: 'authconfig',
ETCD_BACKUP: 'etcdbackup',
CLUSTER_TOKEN: 'clusterregistrationtoken',
CLUSTER_ROLE_TEMPLATE_BINDING: 'clusterRoleTemplateBinding',
GROUP: 'group',
PRINCIPAL: 'principal',
SPOOFED: { GROUP_PRINCIPAL: 'group.principal' },
TOKEN: 'token',
USER: 'user',
};
// Public (via Norman)

View File

@ -0,0 +1,222 @@
<script>
import CreateEditView from '@/mixins/create-edit-view';
import CruResource from '@/components/CruResource';
import SelectPrincipal from '@/components/auth/SelectPrincipal';
import { MANAGEMENT, NORMAN } from '@/config/types';
import RadioGroup from '@/components/form/RadioGroup';
import Card from '@/components/Card';
import Loading from '@/components/Loading';
import Checkbox from '@/components/form/Checkbox';
export default {
components: {
Card,
Checkbox,
CruResource,
Loading,
RadioGroup,
SelectPrincipal
},
mixins: [CreateEditView],
async fetch() {
await this.$store.dispatch('management/findAll', { type: MANAGEMENT.USER });
this.roleTemplates = await this.$store.dispatch('management/findAll', { type: MANAGEMENT.ROLE_TEMPLATE });
},
data() {
return {
customPermissions: [
{
label: this.t('members.clusterPermissions.createProjects'),
key: 'projects-create',
value: false
},
{
label: this.t('members.clusterPermissions.manageClusterBackups'),
key: 'backups-manage',
value: false
},
{
label: this.t('members.clusterPermissions.manageClusterCatalogs'),
key: 'clustercatalogs-manage',
value: false
},
{
label: this.t('members.clusterPermissions.manageClusterMembers'),
key: 'clusterroletemplatebindings-manage',
value: false
},
{
label: this.t('members.clusterPermissions.manageNodes'),
key: 'nodes-manage',
value: false
},
{
label: this.t('members.clusterPermissions.manageStorage'),
key: 'storage-manage',
value: false
},
{
label: this.t('members.clusterPermissions.viewAllProjects'),
key: 'projects-view',
value: false
},
{
label: this.t('members.clusterPermissions.viewClusterCatalogs'),
key: 'clustercatalogs-view',
value: false
},
{
label: this.t('members.clusterPermissions.viewClusterMembers'),
key: 'clusterroletemplatebindings-view',
value: false
},
{
label: this.t('members.clusterPermissions.viewNodes'),
key: 'nodes-view',
value: false
}
],
permissionGroup: 'member',
custom: {},
roleTemplates: [],
userPrincipalId: ''
};
},
computed: {
customRoles() {
return this.roleTemplates
.filter((role) => {
return !role.builtin && !role.external && !role.hidden && role.context === 'cluster';
});
},
doneLocationOverride() {
return this.value.listLocation;
},
roleTemplateIds() {
if (this.permissionGroup === 'owner') {
return ['cluster-owner'];
}
if (this.permissionGroup === 'member') {
return ['cluster-member'];
}
if (this.permissionGroup === 'custom') {
return this.customPermissions
.filter(permission => permission.value)
.map(permission => permission.key);
}
return [this.permissionGroup];
},
options() {
const customRoles = this.customRoles.map(role => ({
label: role.nameDisplay,
description: role.description || this.t('members.clusterPermissions.noDescription'),
value: role.id
}));
return [
{
label: this.t('members.clusterPermissions.owner.label'),
description: this.t('members.clusterPermissions.owner.description'),
value: 'owner'
},
{
label: this.t('members.clusterPermissions.member.label'),
description: this.t('members.clusterPermissions.member.description'),
value: 'member'
},
...customRoles,
{
label: this.t('members.clusterPermissions.custom.label'),
description: this.t('members.clusterPermissions.custom.description'),
value: 'custom'
}
];
}
},
methods: {
onAdd(userId) {
this.$set(this, 'userPrincipalId', userId);
},
async saveOverride() {
const asyncBindings = this.roleTemplateIds.map(roleTemplateId => this.$store.dispatch(`rancher/create`, {
type: NORMAN.CLUSTER_ROLE_TEMPLATE_BINDING,
clusterId: this.$store.getters['currentCluster'].id,
roleTemplateId,
userPrincipalId: this.userPrincipalId
}));
const bindings = await Promise.all(asyncBindings);
await Promise.all(bindings.map(binding => binding.save()));
await this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING, opt: { force: true } });
this.$router.replace(this.value.listLocation);
}
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<CruResource
v-else
class="cluster-role-template-binding"
:errors="errors"
:mode="mode"
:resource="value"
:subtypes="[]"
:can-yaml="false"
:validation-passed="!!userPrincipalId"
@error="e=>errors = e"
@finish="saveOverride"
@cancel="done"
>
<div class="row m-10">
<div class="col span-12">
<SelectPrincipal class="mb-20" :mode="mode" :retain-selection="true" @add="onAdd" />
</div>
</div>
<Card :show-highlight-border="false" :show-actions="false">
<template v-slot:title>
<div class="type-title">
<h3>{{ t('members.clusterPermissions.label') }}</h3>
<div class="type-description">
{{ t('members.clusterPermissions.description') }}
</div>
</div>
</template>
<template v-slot:body>
<RadioGroup
v-model="permissionGroup"
:options="options"
name="permission-group"
/>
<div v-if="permissionGroup === 'custom'" class="custom-permissions ml-20 mt-10">
<Checkbox v-for="permission in customPermissions" :key="permission.key" v-model="permission.value" class="mb-5" :label="permission.label" />
</div>
</template>
</Card>
</CruResource>
</template>
<style lang="scss" scoped>
$detailSize: 11px;
::v-deep .type-description {
font-size: $detailSize;
}
label.radio {
font-size: 16px;
}
.custom-permissions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
</style>

View File

@ -1,6 +1,41 @@
import { _CREATE } from '@/config/query-params';
import { MANAGEMENT } from '@/config/types';
export default {
detailPageHeaderActionOverride() {
return (realMode) => {
if (realMode === _CREATE) {
return this.t('members.createActionLabel');
}
};
},
canCustomEdit() {
return false;
},
canYaml() {
return false;
},
canClone() {
return false;
},
user() {
return this.$rootGetters['management/byId'](MANAGEMENT.USER, this.userName);
},
nameDisplay() {
return this.user?.nameDisplay;
},
roleDisplay() {
return this.roleTemplate.nameDisplay;
},
roleDescription() {
return this.roleTemplate.description;
},
roleTemplate() {
return this.$rootGetters['management/byId'](MANAGEMENT.ROLE_TEMPLATE, this.roleTemplateName);
@ -29,4 +64,20 @@ export default {
return { name, params };
},
listLocation() {
return { name: 'c-cluster-explorer-members' };
},
doneOverride() {
return this.listLocation;
},
parentLocationOverride() {
return this.listLocation;
},
subSearch() {
return [{ nameDisplay: this.nameDisplay }];
}
};

View File

@ -17,6 +17,12 @@ export default {
return !!(this.principalIds || []).find(p => p === currentPrincipal);
},
principals() {
return this.principalIds
.map(id => this.$rootGetters['rancher/byId'](NORMAN.PRINCIPAL, id))
.filter(p => p);
},
nameDisplay() {
return this.displayName || this.username || this.id;
},

View File

@ -113,7 +113,7 @@ export default {
},
projectNameSort() {
return this.project?.nameSort || '}';
return this.project?.nameSort || '';
},
istioInstalled() {

View File

@ -0,0 +1,76 @@
<script>
import { MANAGEMENT } from '@/config/types';
import ResourceTable from '@/components/ResourceTable';
import Loading from '@/components/Loading';
import { NAME } from '@/config/product/explorer';
import Masthead from '@/components/ResourceList/Masthead';
import { AGE, ROLE, STATE, PRINCIPAL } from '@/config/table-headers';
export default {
components: {
Loading, Masthead, ResourceTable
},
async fetch() {
const clusterRoleTemplateBindingSchema = this.$store.getters[`management/schemaFor`](MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING);
const hydration = [
this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.USER }),
this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.ROLE_TEMPLATE })
];
const clusterRoleTemplateBindings = clusterRoleTemplateBindingSchema ? await this.$store.dispatch(`management/findAll`, { type: MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING }) : [];
await Promise.all(hydration);
this.$set(this, 'clusterRoleTemplateBindings', clusterRoleTemplateBindings);
},
data() {
return {
schema: this.$store.getters[`management/schemaFor`](MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING),
headers: [
STATE,
PRINCIPAL,
ROLE,
AGE
],
createLocation: {
name: 'c-cluster-product-resource-create',
params: {
product: NAME,
resource: MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING,
}
},
resource: MANAGEMENT.CLUSTER_ROLE_TEMPLATE_BINDING,
clusterRoleTemplateBindings: null,
};
},
computed: {
filteredClusterRoleTemplateBindings() {
return this.clusterRoleTemplateBindings
.filter(b => !b.user?.isSystem && b.clusterName === this.$store.getters['currentCluster'].id);
}
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Masthead
:schema="schema"
:resource="resource"
:create-location="createLocation"
:create-button-label="t('members.createActionLabel')"
/>
<ResourceTable
:schema="schema"
:headers="headers"
:rows="filteredClusterRoleTemplateBindings"
:groupable="false"
sub-search="subSearch"
:sub-fields="['nameDisplay']"
/>
</div>
</template>

View File

@ -684,7 +684,7 @@ export default {
action: (this.canCustomEdit ? 'goToClone' : 'cloneYaml'),
label: this.t('action.clone'),
icon: 'icon icon-copy',
enabled: this.canCreate && (this.canCustomEdit || this.canYaml),
enabled: this.canClone && this.canCreate && (this.canCustomEdit || this.canYaml),
},
{ divider: true },
{
@ -717,6 +717,10 @@ export default {
return this.hasLink('remove') && this.$rootGetters['type-map/optionsFor'](this.type).isRemovable;
},
canClone() {
return true;
},
canUpdate() {
return this.hasLink('update') && this.$rootGetters['type-map/optionsFor'](this.type).isEditable;
},
@ -839,7 +843,7 @@ export default {
const schema = this.$getters['schemaFor'](this.type);
let url = schema.linkFor('collection');
if ( schema.attributes && schema.attributes.namespaced ) {
if ( schema.attributes && schema.attributes.namespaced && this.metadata && this.metadata.namespace ) {
url += `/${ this.metadata.namespace }`;
}