mirror of
https://github.com/skooner-k8s/skooner.git
synced 2026-08-24 04:16:18 +00:00
Limit menu options based on accounts permissions
This commit is contained in:
@@ -9,10 +9,8 @@ import AddSvg from '../art/addSvg';
|
||||
|
||||
export default class Menu extends Base {
|
||||
async componentDidMount() {
|
||||
const rules = await api.getRules('default');
|
||||
this.setState({rules});
|
||||
|
||||
console.log(rules);
|
||||
const {status} = await api.getRules('default');
|
||||
this.setState({rules: status.resourceRules});
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -28,33 +26,75 @@ export default class Menu extends Base {
|
||||
{/* Cluster */}
|
||||
<Group>
|
||||
<MenuItem title='Cluster' path='' resource='Logo' onClick={onClick} />
|
||||
<MenuItem title='Nodes' path='node' resource='Node' onClick={onClick} />
|
||||
<MenuItem title='Namespaces' path='namespace' resource='Namespace' onClick={onClick} />
|
||||
|
||||
{canView(rules, api.node) && (
|
||||
<MenuItem title='Nodes' path='node' resource='Node' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.namespace) && (
|
||||
<MenuItem title='Namespaces' path='namespace' resource='Namespace' onClick={onClick} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Workloads */}
|
||||
<Group>
|
||||
<MenuItem title='Workloads' path='workload' resource='Deployment' onClick={onClick} />
|
||||
<MenuItem title='Services' path='service' resource='Service' onClick={onClick} />
|
||||
<MenuItem title='Replicas' path='replicaset' resource='ReplicaSet' onClick={onClick} />
|
||||
<MenuItem title='Pods' path='pod' resource='Pod' onClick={onClick} />
|
||||
<MenuItem title='Ingresses' path='ingress' resource='Ingress' onClick={onClick} />
|
||||
<MenuItem title='Config' path='configmap' resource='ConfigMap' onClick={onClick} />
|
||||
{canView(rules, api.deployment) && (
|
||||
<MenuItem title='Workloads' path='workload' resource='Deployment' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.service) && (
|
||||
<MenuItem title='Services' path='service' resource='Service' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.replicaSet) && (
|
||||
<MenuItem title='Replicas' path='replicaset' resource='ReplicaSet' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.pod) && (
|
||||
<MenuItem title='Pods' path='pod' resource='Pod' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.ingress) && (
|
||||
<MenuItem title='Ingresses' path='ingress' resource='Ingress' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.configMap) && (
|
||||
<MenuItem title='Config' path='configmap' resource='ConfigMap' onClick={onClick} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Storage */}
|
||||
<Group>
|
||||
<MenuItem title='Storage' path='storageclass' resource='StorageClass' onClick={onClick} />
|
||||
<MenuItem title='Volumes' path='persistentvolume' resource='PersistentVolume' onClick={onClick} />
|
||||
<MenuItem title='Claims' path='persistentvolumeclaim' resource='PersistentVolumeClaim' onClick={onClick} />
|
||||
{canView(rules, api.storageClass) && (
|
||||
<MenuItem title='Storage' path='storageclass' resource='StorageClass' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.persistentVolume) && (
|
||||
<MenuItem title='Volumes' path='persistentvolume' resource='PersistentVolume' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.persistentVolumeClaim) && (
|
||||
<MenuItem title='Claims' path='persistentvolumeclaim' resource='PersistentVolumeClaim' onClick={onClick} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Security */}
|
||||
<Group>
|
||||
<MenuItem title='Accounts' path='serviceaccount' resource='ServiceAccount' onClick={onClick} />
|
||||
<MenuItem title='Roles' path='role' resource='Role' additionalPaths={['clusterrole']} onClick={onClick} />
|
||||
<MenuItem title='Bindings' path='rolebinding' resource='Role' additionalPaths={['clusterrolebinding']} onClick={onClick} />
|
||||
<MenuItem title='Secrets' path='secret' resource='Secret' onClick={onClick} />
|
||||
{canView(rules, api.serviceAccount) && (
|
||||
<MenuItem title='Accounts' path='serviceaccount' resource='ServiceAccount' onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.role, api.clusterRole) && (
|
||||
<MenuItem title='Roles' path='role' resource='Role' additionalPaths={['clusterrole']} onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.roleBinding, api.clusterRoleBinding) && (
|
||||
<MenuItem title='Bindings' path='rolebinding' resource='Role' additionalPaths={['clusterrolebinding']} onClick={onClick} />
|
||||
)}
|
||||
|
||||
{canView(rules, api.secret) && (
|
||||
<MenuItem title='Secrets' path='secret' resource='Secret' onClick={onClick} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
@@ -83,8 +123,14 @@ export default class Menu extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
function canView(resources, group, resource) {
|
||||
resources.some((x) => {
|
||||
function canView(resourcesRules, ...args) {
|
||||
if (!resourcesRules) return false;
|
||||
|
||||
return args.some(x => canViewResource(resourcesRules, x.resource));
|
||||
}
|
||||
|
||||
function canViewResource(resourcesRules, {group, resource}) {
|
||||
return resourcesRules.some((x) => {
|
||||
const hasVerb = (x.verbs.includes('list') || x.verbs.includes('*'));
|
||||
const hasGroup = (x.apiGroups.includes(group) || x.apiGroups.includes('*'));
|
||||
const hasResource = (x.resources.includes(resource)) || x.resources.includes('*');
|
||||
@@ -92,11 +138,11 @@ function canView(resources, group, resource) {
|
||||
});
|
||||
}
|
||||
|
||||
function MenuItem(item) {
|
||||
function MenuItem(props) {
|
||||
const currentPath = getRootPath();
|
||||
const paths = getPaths(item);
|
||||
const paths = getPaths(props);
|
||||
const className = paths.includes(currentPath) ? 'menu_item menu_itemSelected' : 'menu_item';
|
||||
const {path, title, resource, onClick} = item;
|
||||
const {path, title, resource, onClick} = props;
|
||||
|
||||
return (
|
||||
<a href={`#!${path}`} title={title} className={className} onClick={onClick}>
|
||||
@@ -108,6 +154,8 @@ function MenuItem(item) {
|
||||
|
||||
function Group({children = []}) {
|
||||
if (!Array.isArray(children)) children = [children]; // eslint-disable-line no-param-reassign
|
||||
children = children.filter(Boolean); // eslint-disable-line no-param-reassign
|
||||
|
||||
if (children.length === 0) return null;
|
||||
|
||||
const paths = children.flatMap(x => getPaths(x.props));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import {hasToken} from './services/apiProxy';
|
||||
import {hasToken} from './services/auth';
|
||||
import Account from './views/account';
|
||||
import Auth from './views/auth';
|
||||
import ClusterRole from './views/clusterRole';
|
||||
|
||||
+50
-86
@@ -1,9 +1,35 @@
|
||||
import _ from 'lodash';
|
||||
import {Base64} from 'js-base64';
|
||||
import {request, stream, streamResult, streamResults} from './apiProxy';
|
||||
import {request, post, stream, apiFactory, apiFactoryWithNamespace} from './apiProxy';
|
||||
import log from '../utils/log';
|
||||
|
||||
const JSON_HEADERS = {Accept: 'application/json', 'Content-Type': 'application/json'};
|
||||
const configMap = apiFactoryWithNamespace('', 'v1', 'configmaps');
|
||||
const event = apiFactoryWithNamespace('', 'v1', 'events');
|
||||
const namespaceService = apiFactory('', 'v1', 'namespaces');
|
||||
const node = apiFactory('', 'v1', 'nodes');
|
||||
const persistentVolume = apiFactory('', 'v1', 'persistentvolumes');
|
||||
const persistentVolumeClaim = apiFactoryWithNamespace('', 'v1', 'persistentvolumeclaims');
|
||||
const pod = apiFactoryWithNamespace('', 'v1', 'pods');
|
||||
const secret = apiFactoryWithNamespace('', 'v1', 'secrets');
|
||||
const serviceAccount = apiFactoryWithNamespace('', 'v1', 'serviceaccounts');
|
||||
const serviceService = apiFactoryWithNamespace('', 'v1', 'services');
|
||||
|
||||
const clusterRole = apiFactory('rbac.authorization.k8s.io', 'v1', 'clusterroles');
|
||||
const clusterRoleBinding = apiFactory('rbac.authorization.k8s.io', 'v1', 'clusterrolebindings');
|
||||
const role = apiFactoryWithNamespace('rbac.authorization.k8s.io', 'v1', 'roles');
|
||||
const roleBinding = apiFactoryWithNamespace('rbac.authorization.k8s.io', 'v1', 'rolebindings');
|
||||
|
||||
const daemonSet = apiFactoryWithNamespace('apps', 'v1', 'daemonsets');
|
||||
const deployment = apiFactoryWithNamespace('apps', 'v1', 'deployments', true);
|
||||
const replicaSet = apiFactoryWithNamespace('apps', 'v1', 'replicasets', true);
|
||||
const statefulSet = apiFactoryWithNamespace('apps', 'v1', 'statefulsets', true);
|
||||
|
||||
const cronJob = apiFactoryWithNamespace('batch', 'v1beta1', 'cronjobs');
|
||||
const job = apiFactoryWithNamespace('batch', 'v1', 'jobs');
|
||||
|
||||
const ingress = apiFactoryWithNamespace('extensions', 'v1beta1', 'ingresses');
|
||||
|
||||
const storageClass = apiFactory('storage.k8s.io', 'v1', 'storageclasses');
|
||||
|
||||
const apis = {
|
||||
apply,
|
||||
@@ -15,31 +41,28 @@ const apis = {
|
||||
metrics: metricsFactory(),
|
||||
oidc: oidcFactory(),
|
||||
|
||||
// Non-namespaced apis
|
||||
clusterRole: apiFactory('/apis/rbac.authorization.k8s.io/v1', 'clusterroles'),
|
||||
namespace: apiFactory('/api/v1', 'namespaces'),
|
||||
node: apiFactory('/api/v1', 'nodes'),
|
||||
persistentVolume: apiFactory('/api/v1', 'persistentvolumes'),
|
||||
storageClass: apiFactory('/apis/storage.k8s.io/v1', 'storageclasses'),
|
||||
clusterRoleBinding: apiFactory('/apis/rbac.authorization.k8s.io/v1', 'clusterrolebindings'),
|
||||
|
||||
// Namespaced apis
|
||||
configMap: apiFactoryWithNamespace('/api/v1', 'configmaps'),
|
||||
cronJob: apiFactoryWithNamespace('/apis/batch/v1beta1', 'cronjobs'),
|
||||
daemonSet: apiFactoryWithNamespace('/apis/apps/v1', 'daemonsets'),
|
||||
deployment: apiFactoryWithNamespace('/apis/apps/v1', 'deployments', true),
|
||||
event: apiFactoryWithNamespace('/api/v1', 'events'),
|
||||
ingress: apiFactoryWithNamespace('/apis/extensions/v1beta1', 'ingresses'),
|
||||
job: apiFactoryWithNamespace('/apis/batch/v1', 'jobs'),
|
||||
persistentVolumeClaim: apiFactoryWithNamespace('/api/v1', 'persistentvolumeclaims'),
|
||||
pod: apiFactoryWithNamespace('/api/v1', 'pods'),
|
||||
replicaSet: apiFactoryWithNamespace('/apis/apps/v1', 'replicasets', true),
|
||||
role: apiFactoryWithNamespace('/apis/rbac.authorization.k8s.io/v1', 'roles'),
|
||||
secret: apiFactoryWithNamespace('/api/v1', 'secrets'),
|
||||
service: apiFactoryWithNamespace('/api/v1', 'services'),
|
||||
serviceAccount: apiFactoryWithNamespace('/api/v1', 'serviceaccounts'),
|
||||
statefulSet: apiFactoryWithNamespace('/apis/apps/v1', 'statefulsets', true),
|
||||
roleBinding: apiFactoryWithNamespace('/apis/rbac.authorization.k8s.io/v1', 'rolebindings'),
|
||||
clusterRole,
|
||||
namespace: namespaceService,
|
||||
node,
|
||||
persistentVolume,
|
||||
storageClass,
|
||||
clusterRoleBinding,
|
||||
configMap,
|
||||
cronJob,
|
||||
daemonSet,
|
||||
deployment,
|
||||
event,
|
||||
ingress,
|
||||
job,
|
||||
persistentVolumeClaim,
|
||||
pod,
|
||||
replicaSet,
|
||||
role,
|
||||
secret,
|
||||
service: serviceService,
|
||||
serviceAccount,
|
||||
statefulSet,
|
||||
roleBinding,
|
||||
};
|
||||
|
||||
async function testAuth() {
|
||||
@@ -110,48 +133,6 @@ function metrics(url, cb) {
|
||||
}
|
||||
}
|
||||
|
||||
function apiFactory(apiType, kind) {
|
||||
const url = `${apiType}/${kind}`;
|
||||
return {
|
||||
list: (cb, errCb) => streamResults(url, cb, errCb),
|
||||
get: (name, cb, errCb) => streamResult(url, name, cb, errCb),
|
||||
post: body => post(url, body),
|
||||
put: body => put(`${url}/${body.metadata.name}`, body),
|
||||
delete: name => del(`${url}/${name}`),
|
||||
};
|
||||
}
|
||||
|
||||
function apiFactoryWithNamespace(apiType, kind, includeScale) {
|
||||
const results = {
|
||||
list: (namespace, cb, errCb) => streamResults(url(namespace), cb, errCb),
|
||||
get: (namespace, name, cb, errCb) => streamResult(url(namespace), name, cb, errCb),
|
||||
post: body => post(url(body.metadata.namespace), body),
|
||||
put: body => put(`${url(body.metadata.namespace)}/${body.metadata.name}`, body),
|
||||
delete: (namespace, name) => del(`${url(namespace)}/${name}`),
|
||||
};
|
||||
|
||||
if (includeScale) {
|
||||
results.scale = apiScaleFactory(apiType, kind);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
function url(namespace) {
|
||||
return namespace ? `${apiType}/namespaces/${namespace}/${kind}` : `${apiType}/${kind}`;
|
||||
}
|
||||
}
|
||||
|
||||
function apiScaleFactory(apiType, kind) {
|
||||
return {
|
||||
get: (namespace, name) => request(url(namespace, name)),
|
||||
put: body => put(url(body.metadata.namespace, body.metadata.name), body),
|
||||
};
|
||||
|
||||
function url(namespace, name) {
|
||||
return `${apiType}/namespaces/${namespace}/${kind}/${name}/scale`;
|
||||
}
|
||||
}
|
||||
|
||||
function swagger() {
|
||||
return request('/openapi/v2');
|
||||
}
|
||||
@@ -181,21 +162,4 @@ function logs(namespace, name, container, tailLines, showPrevious, cb) {
|
||||
}
|
||||
}
|
||||
|
||||
function post(url, json, autoLogoutOnAuthError = true) {
|
||||
const body = JSON.stringify(json);
|
||||
const opts = {method: 'POST', body, headers: JSON_HEADERS};
|
||||
return request(url, opts, autoLogoutOnAuthError);
|
||||
}
|
||||
|
||||
function put(url, json, autoLogoutOnAuthError = true) {
|
||||
const body = JSON.stringify(json);
|
||||
const opts = {method: 'PUT', body, headers: JSON_HEADERS};
|
||||
return request(url, opts, autoLogoutOnAuthError);
|
||||
}
|
||||
|
||||
function del(url) {
|
||||
const opts = {method: 'DELETE', headers: JSON_HEADERS};
|
||||
return request(url, opts);
|
||||
}
|
||||
|
||||
export default apis;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as cookie from 'js-cookie';
|
||||
import {getToken, logout} from './auth';
|
||||
import log from '../utils/log';
|
||||
|
||||
const {host, href, hash, search} = window.location;
|
||||
@@ -6,38 +6,7 @@ const nonHashedUrl = href.replace(hash, '').replace(search, '');
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const BASE_HTTP_URL = isDev && host === 'localhost:4653' ? 'http://localhost:4654' : nonHashedUrl;
|
||||
const BASE_WS_URL = BASE_HTTP_URL.replace('http', 'ws');
|
||||
|
||||
const authorizationCookie = cookie.get('Authorization');
|
||||
if (authorizationCookie) {
|
||||
setToken(authorizationCookie);
|
||||
cookie.remove('Authorization');
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.authToken;
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
const user = getToken().split('.')[1];
|
||||
return JSON.parse(atob(user));
|
||||
}
|
||||
|
||||
export function hasToken() {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.authToken = token;
|
||||
}
|
||||
|
||||
export function deleteToken() {
|
||||
delete localStorage.authToken;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
deleteToken();
|
||||
window.location.reload();
|
||||
}
|
||||
const JSON_HEADERS = {Accept: 'application/json', 'Content-Type': 'application/json'};
|
||||
|
||||
export async function request(path, params, autoLogoutOnAuthError = true) {
|
||||
const opts = Object.assign({headers: {}}, params);
|
||||
@@ -71,6 +40,73 @@ export async function request(path, params, autoLogoutOnAuthError = true) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function apiFactory(group, version, resource) {
|
||||
const apiRoot = getApiRoot(group, version);
|
||||
const url = `${apiRoot}/${resource}`;
|
||||
return {
|
||||
resource: {group, resource},
|
||||
list: (cb, errCb) => streamResults(url, cb, errCb),
|
||||
get: (name, cb, errCb) => streamResult(url, name, cb, errCb),
|
||||
post: body => post(url, body),
|
||||
put: body => put(`${url}/${body.metadata.name}`, body),
|
||||
delete: name => remove(`${url}/${name}`),
|
||||
};
|
||||
}
|
||||
|
||||
export function apiFactoryWithNamespace(group, version, resource, includeScale) {
|
||||
const apiRoot = getApiRoot(group, version);
|
||||
const results = {
|
||||
resource: {group, resource},
|
||||
list: (namespace, cb, errCb) => streamResults(url(namespace), cb, errCb),
|
||||
get: (namespace, name, cb, errCb) => streamResult(url(namespace), name, cb, errCb),
|
||||
post: body => post(url(body.metadata.namespace), body),
|
||||
put: body => put(`${url(body.metadata.namespace)}/${body.metadata.name}`, body),
|
||||
delete: (namespace, name) => remove(`${url(namespace)}/${name}`),
|
||||
};
|
||||
|
||||
if (includeScale) {
|
||||
results.scale = apiScaleFactory(apiRoot, resource);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
function url(namespace) {
|
||||
return namespace ? `${apiRoot}/namespaces/${namespace}/${resource}` : `${apiRoot}/${resource}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getApiRoot(group, version) {
|
||||
return group ? `/apis/${group}/${version}` : `api/${version}`;
|
||||
}
|
||||
|
||||
function apiScaleFactory(apiRoot, resource) {
|
||||
return {
|
||||
get: (namespace, name) => request(url(namespace, name)),
|
||||
put: body => put(url(body.metadata.namespace, body.metadata.name), body),
|
||||
};
|
||||
|
||||
function url(namespace, name) {
|
||||
return `${apiRoot}/namespaces/${namespace}/${resource}/${name}/scale`;
|
||||
}
|
||||
}
|
||||
|
||||
export function post(url, json, autoLogoutOnAuthError = true) {
|
||||
const body = JSON.stringify(json);
|
||||
const opts = {method: 'POST', body, headers: JSON_HEADERS};
|
||||
return request(url, opts, autoLogoutOnAuthError);
|
||||
}
|
||||
|
||||
export function put(url, json, autoLogoutOnAuthError = true) {
|
||||
const body = JSON.stringify(json);
|
||||
const opts = {method: 'PUT', body, headers: JSON_HEADERS};
|
||||
return request(url, opts, autoLogoutOnAuthError);
|
||||
}
|
||||
|
||||
export function remove(url) {
|
||||
const opts = {method: 'DELETE', headers: JSON_HEADERS};
|
||||
return request(url, opts);
|
||||
}
|
||||
|
||||
export async function streamResult(url, name, cb, errCb) {
|
||||
let isCancelled = false;
|
||||
let socket;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as cookie from 'js-cookie';
|
||||
|
||||
// If we have an "Authorization" cookie, use that as the token for future api calls
|
||||
const authorizationCookie = cookie.get('Authorization');
|
||||
if (authorizationCookie) {
|
||||
setToken(authorizationCookie);
|
||||
cookie.remove('Authorization');
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
// This line deals with backwards compatability from when we used to only store the actual jwt
|
||||
if (localStorage.authToken && !localStorage.authToken.startsWith('Bearer ')) {
|
||||
localStorage.authToken = `Bearer ${localStorage.authToken}`;
|
||||
}
|
||||
|
||||
return localStorage.authToken;
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
const user = getToken().split('.')[1];
|
||||
return JSON.parse(atob(user));
|
||||
}
|
||||
|
||||
export function hasToken() {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.authToken = token;
|
||||
}
|
||||
|
||||
export function deleteToken() {
|
||||
delete localStorage.authToken;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
deleteToken();
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import Button from '../components/button';
|
||||
import ItemHeader from '../components/itemHeader';
|
||||
import {getUserInfo, logout} from '../services/apiProxy';
|
||||
import {getUserInfo, logout} from '../services/auth';
|
||||
import LogoutSvg from '../art/logoutSvg';
|
||||
|
||||
const Account = () => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './auth.scss';
|
||||
import React from 'react';
|
||||
import {addUserNotification} from '../components/notifier';
|
||||
import {setToken, deleteToken} from '../services/apiProxy';
|
||||
import {setToken, deleteToken} from '../services/auth';
|
||||
import api from '../services/api';
|
||||
import Base from '../components/base';
|
||||
import Button from '../components/button';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import Base from '../components/base';
|
||||
import api from '../services/api';
|
||||
@@ -48,7 +49,7 @@ export default class ConfigMap extends Base {
|
||||
</div>
|
||||
|
||||
<div className='contentPanel'>
|
||||
{!item ? <Loading /> : Object.entries(item.data || {}).map(([key, value]) => (
|
||||
{!item ? <Loading /> : _.map(item.data, (value, key) => (
|
||||
<Field key={key} name={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user