Merge branch 'application-mgt-new' into 'application-mgt-new'

Sync master branch in to application-mgt-new

See merge request entgra/carbon-device-mgt!268
feature/appm-store/pbac
Dharmakeerthi Lasantha 5 years ago
commit 252b9194cf

@ -27,6 +27,8 @@ import org.wso2.carbon.apimgt.application.extension.api.util.RegistrationProfile
import org.wso2.carbon.apimgt.application.extension.constants.ApiApplicationConstants; import org.wso2.carbon.apimgt.application.extension.constants.ApiApplicationConstants;
import org.wso2.carbon.apimgt.application.extension.dto.ApiApplicationKey; import org.wso2.carbon.apimgt.application.extension.dto.ApiApplicationKey;
import org.wso2.carbon.apimgt.application.extension.exception.APIManagerException; import org.wso2.carbon.apimgt.application.extension.exception.APIManagerException;
import org.wso2.carbon.apimgt.integration.client.OAuthRequestInterceptor;
import org.wso2.carbon.apimgt.integration.client.store.StoreClient;
import org.wso2.carbon.base.MultitenantConstants; import org.wso2.carbon.base.MultitenantConstants;
import org.wso2.carbon.context.PrivilegedCarbonContext; import org.wso2.carbon.context.PrivilegedCarbonContext;
import org.wso2.carbon.device.mgt.common.exceptions.DeviceManagementException; import org.wso2.carbon.device.mgt.common.exceptions.DeviceManagementException;
@ -94,9 +96,8 @@ public class ApiApplicationRegistrationServiceImpl implements ApiApplicationRegi
return Response.status(Response.Status.NOT_ACCEPTABLE).entity("APIs(Tags) are not allowed to this user." return Response.status(Response.Status.NOT_ACCEPTABLE).entity("APIs(Tags) are not allowed to this user."
).build(); ).build();
} }
PrivilegedCarbonContext.getThreadLocalCarbonContext().setUsername(PrivilegedCarbonContext.
getThreadLocalCarbonContext().getUserRealm().getRealmConfiguration().getAdminUserName());
String username = APIUtil.getAuthenticatedUser(); String username = APIUtil.getAuthenticatedUser();
APIManagementProviderService apiManagementProviderService = APIUtil.getAPIManagementProviderService(); APIManagementProviderService apiManagementProviderService = APIUtil.getAPIManagementProviderService();
String validityPeriod; String validityPeriod;
if (registrationProfile.getValidityPeriod() == null) { if (registrationProfile.getValidityPeriod() == null) {
@ -106,6 +107,22 @@ public class ApiApplicationRegistrationServiceImpl implements ApiApplicationRegi
} }
String applicationName = registrationProfile.getApplicationName(); String applicationName = registrationProfile.getApplicationName();
if (username.equals(registrationProfile.getUsername())) {
synchronized (ApiApplicationRegistrationServiceImpl.class) {
StoreClient storeClient = new StoreClient(new OAuthRequestInterceptor(registrationProfile.getUsername(),
registrationProfile.getPassword()));
ApiApplicationKey apiApplicationKey = apiManagementProviderService.generateAndRetrieveApplicationKeys(
applicationName, registrationProfile.getTags(),
ApiApplicationConstants.DEFAULT_TOKEN_TYPE, username,
registrationProfile.isAllowedToAllDomains(), validityPeriod, storeClient);
return Response.status(Response.Status.CREATED).entity(apiApplicationKey.toString()).build();
}
}
PrivilegedCarbonContext.getThreadLocalCarbonContext().setUsername(PrivilegedCarbonContext.
getThreadLocalCarbonContext().getUserRealm().getRealmConfiguration().getAdminUserName());
synchronized (ApiApplicationRegistrationServiceImpl.class) { synchronized (ApiApplicationRegistrationServiceImpl.class) {
ApiApplicationKey apiApplicationKey = apiManagementProviderService.generateAndRetrieveApplicationKeys( ApiApplicationKey apiApplicationKey = apiManagementProviderService.generateAndRetrieveApplicationKeys(
applicationName, registrationProfile.getTags(), applicationName, registrationProfile.getTags(),

@ -32,6 +32,10 @@ import javax.xml.bind.annotation.XmlRootElement;
public class RegistrationProfile { public class RegistrationProfile {
@XmlElement(required = true) @XmlElement(required = true)
private String applicationName; private String applicationName;
@XmlElement
private String username;
@XmlElement
private String password;
@XmlElement(required = true) @XmlElement(required = true)
private String tags[]; private String tags[];
@XmlElement(required = true) @XmlElement(required = true)
@ -70,4 +74,20 @@ public class RegistrationProfile {
public void setValidityPeriod(String validityPeriod) { public void setValidityPeriod(String validityPeriod) {
this.validityPeriod = validityPeriod; this.validityPeriod = validityPeriod;
} }
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
} }

@ -20,6 +20,7 @@ package org.wso2.carbon.apimgt.application.extension;
import org.wso2.carbon.apimgt.application.extension.dto.ApiApplicationKey; import org.wso2.carbon.apimgt.application.extension.dto.ApiApplicationKey;
import org.wso2.carbon.apimgt.application.extension.exception.APIManagerException; import org.wso2.carbon.apimgt.application.extension.exception.APIManagerException;
import org.wso2.carbon.apimgt.integration.client.store.StoreClient;
/** /**
* This comprise on operation that is been done with api manager from CDMF. This service needs to be implemented in APIM. * This comprise on operation that is been done with api manager from CDMF. This service needs to be implemented in APIM.
@ -49,6 +50,28 @@ public interface APIManagementProviderService {
String keyType, String username, boolean isAllowedAllDomains, String keyType, String username, boolean isAllowedAllDomains,
String validityTime) throws APIManagerException; String validityTime) throws APIManagerException;
/**
* Generate and retreive application keys. if the application does exist then
* create it and subscribe to apis that are grouped with the tags.
*
* @param apiApplicationName name of the application.
* @param tags tags of the apis that application needs to be subscribed.
* @param keyType of the application.
* @param username to whom the application is created
* @param isAllowedAllDomains application is allowed to all the tenants
* @param validityTime validity period of the application
* @param storeClient Specified store client
* @return consumerkey and secrete of the created application.
* @throws APIManagerException
*/
ApiApplicationKey generateAndRetrieveApplicationKeys(String apiApplicationName,
String tags[],
String keyType,
String username,
boolean isAllowedAllDomains,
String validityTime,
StoreClient storeClient) throws APIManagerException;
/** /**
* Remove APIM Application. * Remove APIM Application.
*/ */

@ -94,11 +94,18 @@ public class APIManagementProviderServiceImpl implements APIManagementProviderSe
@Override @Override
public synchronized ApiApplicationKey generateAndRetrieveApplicationKeys(String applicationName, String tags[], public synchronized ApiApplicationKey generateAndRetrieveApplicationKeys(String applicationName, String tags[],
String keyType, String username, String keyType, String username,
boolean isAllowedAllDomains, String validityTime) boolean isAllowedAllDomains, String validityTime,
throws APIManagerException { StoreClient sClient) throws APIManagerException {
StoreClient storeClient =
APIApplicationManagerExtensionDataHolder.getInstance().getIntegrationClientService() StoreClient storeClient;
if (sClient == null) {
storeClient = APIApplicationManagerExtensionDataHolder.getInstance().getIntegrationClientService()
.getStoreClient(); .getStoreClient();
} else {
storeClient = sClient;
}
String tenantDomain = PrivilegedCarbonContext.getThreadLocalCarbonContext() String tenantDomain = PrivilegedCarbonContext.getThreadLocalCarbonContext()
.getTenantDomain(); .getTenantDomain();
try { try {
@ -211,4 +218,16 @@ public class APIManagementProviderServiceImpl implements APIManagementProviderSe
} }
} }
/**
* {@inheritDoc}
*/
@Override
public synchronized ApiApplicationKey generateAndRetrieveApplicationKeys(String applicationName, String tags[],
String keyType, String username,
boolean isAllowedAllDomains,
String validityTime)
throws APIManagerException {
return this.generateAndRetrieveApplicationKeys(applicationName, tags, keyType, username,
isAllowedAllDomains, validityTime, null);
}
} }

@ -36,6 +36,12 @@ public class IntegrationClientServiceImpl implements IntegrationClientService {
publisherClient = new PublisherClient(oAuthRequestInterceptor); publisherClient = new PublisherClient(oAuthRequestInterceptor);
} }
public IntegrationClientServiceImpl(OAuthRequestInterceptor oAuthRequestInterceptor) {
this.oAuthRequestInterceptor = oAuthRequestInterceptor;
storeClient = new StoreClient(oAuthRequestInterceptor);
publisherClient = new PublisherClient(oAuthRequestInterceptor);
}
public static IntegrationClientServiceImpl getInstance() { public static IntegrationClientServiceImpl getInstance() {
if (instance == null) { if (instance == null) {
synchronized (IntegrationClientService.class) { synchronized (IntegrationClientService.class) {

@ -56,7 +56,7 @@ public class OAuthRequestInterceptor implements RequestInterceptor {
private static final String APIM_SUBSCRIBE_SCOPE = "apim:subscribe"; private static final String APIM_SUBSCRIBE_SCOPE = "apim:subscribe";
private static final long DEFAULT_REFRESH_TIME_OFFSET_IN_MILLIS = 100000; private static final long DEFAULT_REFRESH_TIME_OFFSET_IN_MILLIS = 100000;
private DCRClient dcrClient; private DCRClient dcrClient;
private static OAuthApplication oAuthApplication; private OAuthApplication oAuthApplication;
private static Map<String, AccessTokenInfo> tenantUserTokenMap = new ConcurrentHashMap<>(); private static Map<String, AccessTokenInfo> tenantUserTokenMap = new ConcurrentHashMap<>();
private static final Log log = LogFactory.getLog(OAuthRequestInterceptor.class); private static final Log log = LogFactory.getLog(OAuthRequestInterceptor.class);
@ -67,8 +67,15 @@ public class OAuthRequestInterceptor implements RequestInterceptor {
String username = APIMConfigReader.getInstance().getConfig().getUsername(); String username = APIMConfigReader.getInstance().getConfig().getUsername();
String password = APIMConfigReader.getInstance().getConfig().getPassword(); String password = APIMConfigReader.getInstance().getConfig().getPassword();
dcrClient = Feign.builder().client(new OkHttpClient(Utils.getSSLClient())).logger(new Slf4jLogger()) dcrClient = Feign.builder().client(new OkHttpClient(Utils.getSSLClient())).logger(new Slf4jLogger())
.logLevel(Logger.Level.FULL).requestInterceptor(new BasicAuthRequestInterceptor(username, .logLevel(Logger.Level.FULL).requestInterceptor(new BasicAuthRequestInterceptor(username, password))
password)) .contract(new JAXRSContract()).encoder(new GsonEncoder()).decoder(new GsonDecoder())
.target(DCRClient.class, Utils.replaceProperties(
APIMConfigReader.getInstance().getConfig().getDcrEndpoint()));
}
public OAuthRequestInterceptor(String username, String password) {
dcrClient = Feign.builder().client(new OkHttpClient(Utils.getSSLClient())).logger(new Slf4jLogger())
.logLevel(Logger.Level.FULL).requestInterceptor(new BasicAuthRequestInterceptor(username, password))
.contract(new JAXRSContract()).encoder(new GsonEncoder()).decoder(new GsonDecoder()) .contract(new JAXRSContract()).encoder(new GsonEncoder()).decoder(new GsonDecoder())
.target(DCRClient.class, Utils.replaceProperties( .target(DCRClient.class, Utils.replaceProperties(
APIMConfigReader.getInstance().getConfig().getDcrEndpoint())); APIMConfigReader.getInstance().getConfig().getDcrEndpoint()));
@ -82,7 +89,11 @@ public class OAuthRequestInterceptor implements RequestInterceptor {
clientProfile.setClientName(APPLICATION_NAME); clientProfile.setClientName(APPLICATION_NAME);
clientProfile.setCallbackUrl(""); clientProfile.setCallbackUrl("");
clientProfile.setGrantType(GRANT_TYPES); clientProfile.setGrantType(GRANT_TYPES);
clientProfile.setOwner(APIMConfigReader.getInstance().getConfig().getUsername()); String username = PrivilegedCarbonContext.getThreadLocalCarbonContext().getUsername();
if (username == null || username.isEmpty()) {
username = APIMConfigReader.getInstance().getConfig().getUsername();
}
clientProfile.setOwner(username);
clientProfile.setSaasApp(true); clientProfile.setSaasApp(true);
oAuthApplication = dcrClient.register(clientProfile); oAuthApplication = dcrClient.register(clientProfile);
} }
@ -100,8 +111,7 @@ public class OAuthRequestInterceptor implements RequestInterceptor {
JWTClient jwtClient = APIIntegrationClientDataHolder.getInstance().getJwtClientManagerService() JWTClient jwtClient = APIIntegrationClientDataHolder.getInstance().getJwtClientManagerService()
.getJWTClient(); .getJWTClient();
tenantBasedAccessTokenInfo = jwtClient.getAccessToken(oAuthApplication.getClientId(), tenantBasedAccessTokenInfo = jwtClient.getAccessToken(oAuthApplication.getClientId(),
oAuthApplication.getClientSecret(), username, oAuthApplication.getClientSecret(), username, REQUIRED_SCOPE);
REQUIRED_SCOPE);
tenantBasedAccessTokenInfo.setExpiresIn( tenantBasedAccessTokenInfo.setExpiresIn(
System.currentTimeMillis() + (tenantBasedAccessTokenInfo.getExpiresIn() * 1000)); System.currentTimeMillis() + (tenantBasedAccessTokenInfo.getExpiresIn() * 1000));
if (tenantBasedAccessTokenInfo.getScopes() == null) { if (tenantBasedAccessTokenInfo.getScopes() == null) {

@ -54,7 +54,7 @@ public class APIPublisherServiceTest extends BaseAPIPublisherTest {
@BeforeTest @BeforeTest
public void initialConfigs() throws Exception { public void initialConfigs() throws Exception {
initializeOAuthApplication(); //initializeOAuthApplication();
WebappPublisherConfig.init(); WebappPublisherConfig.init();
} }

@ -35,6 +35,9 @@ import org.wso2.carbon.device.mgt.common.configuration.mgt.DeviceConfiguration;
import org.wso2.carbon.device.mgt.common.exceptions.DeviceManagementException; import org.wso2.carbon.device.mgt.common.exceptions.DeviceManagementException;
import org.wso2.carbon.device.mgt.common.exceptions.DeviceNotFoundException; import org.wso2.carbon.device.mgt.common.exceptions.DeviceNotFoundException;
import org.wso2.carbon.device.mgt.core.DeviceManagementConstants; import org.wso2.carbon.device.mgt.core.DeviceManagementConstants;
import org.wso2.carbon.device.mgt.core.config.DeviceConfigurationManager;
import org.wso2.carbon.device.mgt.core.config.DeviceManagementConfig;
import org.wso2.carbon.device.mgt.core.config.keymanager.KeyManagerConfigurations;
import org.wso2.carbon.device.mgt.core.service.DeviceManagementProviderService; import org.wso2.carbon.device.mgt.core.service.DeviceManagementProviderService;
import org.wso2.carbon.device.mgt.core.util.DeviceManagerUtil; import org.wso2.carbon.device.mgt.core.util.DeviceManagerUtil;
import org.wso2.carbon.identity.jwt.client.extension.dto.AccessTokenInfo; import org.wso2.carbon.identity.jwt.client.extension.dto.AccessTokenInfo;
@ -163,13 +166,13 @@ public class DeviceManagementConfigServiceImpl implements DeviceManagementConfig
private void setAccessTokenToDeviceConfigurations(DeviceConfiguration devicesConfiguration) private void setAccessTokenToDeviceConfigurations(DeviceConfiguration devicesConfiguration)
throws DeviceManagementException { throws DeviceManagementException {
try { try {
AppRegistrationCredentials credentials = DeviceManagementConfig deviceManagementConfig = DeviceConfigurationManager.getInstance().getDeviceManagementConfig();
DeviceManagerUtil.getApplicationRegistrationCredentials( KeyManagerConfigurations kmConfig = deviceManagementConfig.getKeyManagerConfigurations();
System.getProperty(DeviceManagementConstants AppRegistrationCredentials credentials = DeviceManagerUtil.getApplicationRegistrationCredentials(
.ConfigurationManagement.IOT_GATEWAY_HOST), System.getProperty(DeviceManagementConstants.ConfigurationManagement.IOT_GATEWAY_HOST),
System.getProperty(DeviceManagementConstants System.getProperty(DeviceManagementConstants.ConfigurationManagement.IOT_GATEWAY_HTTPS_PORT),
.ConfigurationManagement.IOT_GATEWAY_HTTPS_PORT), kmConfig.getAdminUsername(),
DeviceManagementConstants.ConfigurationManagement.ADMIN_CREDENTIALS); kmConfig.getAdminPassword());
AccessTokenInfo accessTokenForAdmin = DeviceManagerUtil.getAccessTokenForDeviceOwner( AccessTokenInfo accessTokenForAdmin = DeviceManagerUtil.getAccessTokenForDeviceOwner(
DeviceManagementConstants.ConfigurationManagement.SCOPES_FOR_TOKEN, DeviceManagementConstants.ConfigurationManagement.SCOPES_FOR_TOKEN,
credentials.getClient_id(), credentials.getClient_secret(), credentials.getClient_id(), credentials.getClient_secret(),

@ -1627,7 +1627,7 @@ public interface DeviceManagementService {
@Valid OperationRequest operationRequest); @Valid OperationRequest operationRequest);
@GET @GET
@Path("/status/count/{type}/{status}") @Path("/status/count/{tenantDomain}/{type}/{status}")
@ApiOperation( @ApiOperation(
produces = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON,
httpMethod = "GET", httpMethod = "GET",
@ -1678,6 +1678,13 @@ public interface DeviceManagementService {
response = ErrorResponse.class) response = ErrorResponse.class)
}) })
Response getDeviceCountByStatus( Response getDeviceCountByStatus(
@ApiParam(
name = "tenantDomain",
value = "The tenant doamin.",
required = true)
@PathParam("tenantDomain")
@Size(max = 45)
String tenantDomain,
@ApiParam( @ApiParam(
name = "type", name = "type",
value = "The device type name, such as ios, android, windows or fire-alarm.", value = "The device type name, such as ios, android, windows or fire-alarm.",
@ -1695,7 +1702,7 @@ public interface DeviceManagementService {
@GET @GET
@Path("/status/ids/{type}/{status}") @Path("/status/ids/{tenantDomain}/{type}/{status}")
@ApiOperation( @ApiOperation(
produces = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON,
httpMethod = "GET", httpMethod = "GET",
@ -1747,6 +1754,13 @@ public interface DeviceManagementService {
response = ErrorResponse.class) response = ErrorResponse.class)
}) })
Response getDeviceIdentifiersByStatus( Response getDeviceIdentifiersByStatus(
@ApiParam(
name = "tenantDomain",
value = "The tenant domain.",
required = true)
@PathParam("tenantDomain")
@Size(max = 45)
String tenantDomain,
@ApiParam( @ApiParam(
name = "type", name = "type",
value = "The device type name, such as ios, android, windows or fire-alarm.", value = "The device type name, such as ios, android, windows or fire-alarm.",
@ -1763,7 +1777,7 @@ public interface DeviceManagementService {
String status); String status);
@PUT @PUT
@Path("/status/update/{type}/{status}") @Path("/status/update/{tenantDomain}/{type}/{status}")
@ApiOperation( @ApiOperation(
produces = MediaType.APPLICATION_JSON, produces = MediaType.APPLICATION_JSON,
consumes = MediaType.APPLICATION_JSON, consumes = MediaType.APPLICATION_JSON,
@ -1814,7 +1828,10 @@ public interface DeviceManagementService {
"Server error occurred while retrieving information requested device.", "Server error occurred while retrieving information requested device.",
response = ErrorResponse.class) response = ErrorResponse.class)
}) })
Response bulkUpdateDeviceStatus(@ApiParam(name = "type", value = "The device type, such as ios, android or windows.", required = true) Response bulkUpdateDeviceStatus(
@ApiParam(name = "tenantDomain", value = "The tenant domain.", required = true)
@PathParam("tenantDomain") String tenantDomain,
@ApiParam(name = "type", value = "The device type, such as ios, android or windows.", required = true)
@PathParam("type") String type, @PathParam("type") String type,
@ApiParam(name = "status", value = "The device type, such as ios, android or windows.", required = true) @ApiParam(name = "status", value = "The device type, such as ios, android or windows.", required = true)
@PathParam("status") String status, @PathParam("status") String status,

@ -84,6 +84,7 @@ import org.wso2.carbon.device.mgt.jaxrs.service.impl.util.RequestValidationUtil;
import org.wso2.carbon.device.mgt.jaxrs.util.DeviceMgtAPIUtils; import org.wso2.carbon.device.mgt.jaxrs.util.DeviceMgtAPIUtils;
import org.wso2.carbon.policy.mgt.common.PolicyManagementException; import org.wso2.carbon.policy.mgt.common.PolicyManagementException;
import org.wso2.carbon.policy.mgt.core.PolicyManagerService; import org.wso2.carbon.policy.mgt.core.PolicyManagerService;
import org.wso2.carbon.user.api.UserStoreException;
import org.wso2.carbon.utils.multitenancy.MultitenantUtils; import org.wso2.carbon.utils.multitenancy.MultitenantUtils;
import javax.validation.Valid; import javax.validation.Valid;
@ -918,48 +919,66 @@ public class DeviceManagementServiceImpl implements DeviceManagementService {
@GET @GET
@Override @Override
@Path("/status/count/{type}/{status}") @Path("/status/count/{tenantDomain}/{type}/{status}")
public Response getDeviceCountByStatus(@PathParam("type") String type, @PathParam("status") String status) { public Response getDeviceCountByStatus(@PathParam("tenantDomain") String tenantDomain, @PathParam("type") String type, @PathParam("status") String status) {
int deviceCount; int deviceCount;
try { try {
deviceCount = DeviceMgtAPIUtils.getDeviceManagementService().getDeviceCountOfTypeByStatus(type, status); int tenantId = DeviceMgtAPIUtils.getRealmService().getTenantManager().getTenantId(tenantDomain);
deviceCount = DeviceMgtAPIUtils.getDeviceManagementService().getDeviceCountOfTypeByStatus(tenantId, type, status);
return Response.status(Response.Status.OK).entity(deviceCount).build(); return Response.status(Response.Status.OK).entity(deviceCount).build();
} catch (DeviceManagementException e) { } catch (DeviceManagementException e) {
String errorMessage = "Error while retrieving device count."; String errorMessage = "Error while retrieving device count.";
log.error(errorMessage, e); log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity( return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build(); new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} catch (UserStoreException e) {
String errorMessage = "Error resolving tenant Domain";
log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} }
} }
@GET @GET
@Override @Override
@Path("/status/ids/{type}/{status}") @Path("/status/ids/{tenantDomain}/{type}/{status}")
public Response getDeviceIdentifiersByStatus(@PathParam("type") String type, @PathParam("status") String status) { public Response getDeviceIdentifiersByStatus(@PathParam("tenantDomain") String tenantDomain, @PathParam("type") String type, @PathParam("status") String status) {
List<String> deviceIds; List<String> deviceIds;
try { try {
deviceIds = DeviceMgtAPIUtils.getDeviceManagementService().getDeviceIdentifiersByStatus(type, status); int tenantId = DeviceMgtAPIUtils.getRealmService().getTenantManager().getTenantId(tenantDomain);
deviceIds = DeviceMgtAPIUtils.getDeviceManagementService().getDeviceIdentifiersByStatus(tenantId, type, status);
return Response.status(Response.Status.OK).entity(deviceIds.toArray(new String[0])).build(); return Response.status(Response.Status.OK).entity(deviceIds.toArray(new String[0])).build();
} catch (DeviceManagementException e) { } catch (DeviceManagementException e) {
String errorMessage = "Error while obtaining list of devices"; String errorMessage = "Error while obtaining list of devices";
log.error(errorMessage, e); log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity( return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build(); new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} catch (UserStoreException e) {
String errorMessage = "Error resolving tenant Domain";
log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} }
} }
@PUT @PUT
@Override @Override
@Path("/status/update/{type}/{status}") @Path("/status/update/{tenantDomain}/{type}/{status}")
public Response bulkUpdateDeviceStatus(@PathParam("type") String type, @PathParam("status") String status, public Response bulkUpdateDeviceStatus(@PathParam("tenantDomain") String tenantDomain, @PathParam("type") String type,
@Valid List<String> deviceList) { @PathParam("status") String status, @Valid List<String> deviceList) {
try { try {
DeviceMgtAPIUtils.getDeviceManagementService().bulkUpdateDeviceStatus(type, deviceList, status); int tenantId = DeviceMgtAPIUtils.getRealmService().getTenantManager().getTenantId(tenantDomain);
DeviceMgtAPIUtils.getDeviceManagementService().bulkUpdateDeviceStatus(tenantId, type, deviceList, status);
} catch (DeviceManagementException e) { } catch (DeviceManagementException e) {
String errorMessage = "Error while updating device status in bulk."; String errorMessage = "Error while updating device status in bulk.";
log.error(errorMessage, e); log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity( return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build(); new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} catch (UserStoreException e) {
String errorMessage = "Error resolving tenant Domain";
log.error(errorMessage, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(
new ErrorResponse.ErrorResponseBuilder().setMessage(errorMessage).build()).build();
} }
return Response.status(Response.Status.OK).build(); return Response.status(Response.Status.OK).build();
} }

@ -115,6 +115,7 @@ public class UserManagementServiceImpl implements UserManagementService {
private static final Log log = LogFactory.getLog(UserManagementServiceImpl.class); private static final Log log = LogFactory.getLog(UserManagementServiceImpl.class);
private static final String DEFAULT_DEVICE_USER = "Internal/devicemgt-user"; private static final String DEFAULT_DEVICE_USER = "Internal/devicemgt-user";
private static final String DEFAULT_SUBSCRIBER = "Internal/subscriber";
// Permissions that are given for a normal device user. // Permissions that are given for a normal device user.
private static final Permission[] PERMISSIONS_FOR_DEVICE_USER = { private static final Permission[] PERMISSIONS_FOR_DEVICE_USER = {
@ -158,9 +159,31 @@ public class UserManagementServiceImpl implements UserManagementService {
List<String> tmpRoles = new ArrayList<>(); List<String> tmpRoles = new ArrayList<>();
String[] userInfoRoles = userInfo.getRoles(); String[] userInfoRoles = userInfo.getRoles();
tmpRoles.add(DEFAULT_DEVICE_USER); tmpRoles.add(DEFAULT_DEVICE_USER);
boolean subscriberFound = false;
if (userInfoRoles != null) { if (userInfoRoles != null) {
//check if subscriber role is coming in the payload
for (String r : userInfoRoles) {
if (DEFAULT_SUBSCRIBER.equals(r)) {
subscriberFound = true;
break;
}
}
tmpRoles.addAll(Arrays.asList(userInfoRoles)); tmpRoles.addAll(Arrays.asList(userInfoRoles));
} }
if (!subscriberFound) {
// Add Internal/subscriber role to new users
if (userStoreManager.isExistingRole(DEFAULT_SUBSCRIBER)) {
tmpRoles.add(DEFAULT_SUBSCRIBER);
} else {
log.warn("User: " + userInfo.getUsername() + " will not be able to enroll devices as '" +
DEFAULT_SUBSCRIBER + "' is missing in the system");
}
}
String[] roles = new String[tmpRoles.size()]; String[] roles = new String[tmpRoles.size()];
tmpRoles.toArray(roles); tmpRoles.toArray(roles);

@ -40,8 +40,12 @@ public class GroupManagementAdminServiceImpl implements GroupManagementAdminServ
try { try {
RequestValidationUtil.validatePaginationParameters(offset, limit); RequestValidationUtil.validatePaginationParameters(offset, limit);
GroupPaginationRequest request = new GroupPaginationRequest(offset, limit); GroupPaginationRequest request = new GroupPaginationRequest(offset, limit);
request.setGroupName(name); if (name != null){
request.setOwner(owner); request.setGroupName(name.toUpperCase());
}
if (owner != null) {
request.setOwner(owner.toUpperCase());
}
PaginationResult deviceGroupsResult = DeviceMgtAPIUtils.getGroupManagementProviderService() PaginationResult deviceGroupsResult = DeviceMgtAPIUtils.getGroupManagementProviderService()
.getGroups(request); .getGroups(request);
DeviceGroupList deviceGroupList = new DeviceGroupList(); DeviceGroupList deviceGroupList = new DeviceGroupList();

@ -31,11 +31,9 @@ public final class DeviceManagementConstants {
private ConfigurationManagement(){ private ConfigurationManagement(){
throw new AssertionError(); throw new AssertionError();
} }
public static final String ADMIN_CREDENTIALS = "admin:admin";
public static final String SCOPES_FOR_TOKEN = "perm:device:operations perm:device:publish-event"; public static final String SCOPES_FOR_TOKEN = "perm:device:operations perm:device:publish-event";
public static final String IOT_GATEWAY_HOST = "iot.gateway.host"; public static final String IOT_GATEWAY_HOST = "iot.gateway.host";
public static final String IOT_GATEWAY_HTTPS_PORT = "iot.gateway.https.port"; public static final String IOT_GATEWAY_HTTPS_PORT = "iot.gateway.https.port";
public static final String MQTT_ENDPOINTS = "mqttEndpoints";
public static final String APPLICATION_REGISTRATION_API_ENDPOINT = public static final String APPLICATION_REGISTRATION_API_ENDPOINT =
"/api-application-registration/register"; "/api-application-registration/register";
public static final String AUTHORIZATION_HEADER = "authorization"; public static final String AUTHORIZATION_HEADER = "authorization";

@ -55,11 +55,11 @@ public class GenericGroupDAOImpl extends AbstractGroupDAOImpl {
Connection conn = GroupManagementDAOFactory.getConnection(); Connection conn = GroupManagementDAOFactory.getConnection();
String sql = "SELECT ID, DESCRIPTION, GROUP_NAME, OWNER FROM DM_GROUP WHERE TENANT_ID = ?"; String sql = "SELECT ID, DESCRIPTION, GROUP_NAME, OWNER FROM DM_GROUP WHERE TENANT_ID = ?";
if (groupName != null && !groupName.isEmpty()) { if (groupName != null && !groupName.isEmpty()) {
sql += " AND GROUP_NAME LIKE ?"; sql += " AND UPPER(GROUP_NAME) LIKE ?";
hasGroupName = true; hasGroupName = true;
} }
if (owner != null && !owner.isEmpty()) { if (owner != null && !owner.isEmpty()) {
sql += " AND OWNER LIKE ?"; sql += " AND UPPER(OWNER) LIKE ?";
hasOwner = true; hasOwner = true;
} }
if (hasLimit) { if (hasLimit) {

@ -727,11 +727,11 @@ public interface DeviceManagementProviderService {
List<GeoCluster> findGeoClusters(String deviceType, GeoCoordinate southWest, GeoCoordinate northEast, List<GeoCluster> findGeoClusters(String deviceType, GeoCoordinate southWest, GeoCoordinate northEast,
int geohashLength) throws DeviceManagementException; int geohashLength) throws DeviceManagementException;
int getDeviceCountOfTypeByStatus(String deviceType, String deviceStatus) throws DeviceManagementException; int getDeviceCountOfTypeByStatus(int tenantId, String deviceType, String deviceStatus) throws DeviceManagementException;
List<String> getDeviceIdentifiersByStatus(String deviceType, String deviceStatus) throws DeviceManagementException; List<String> getDeviceIdentifiersByStatus(int tenantId, String deviceType, String deviceStatus) throws DeviceManagementException;
boolean bulkUpdateDeviceStatus(String deviceType, List<String> deviceList, String status) throws DeviceManagementException; boolean bulkUpdateDeviceStatus(int tenantId, String deviceType, List<String> deviceList, String status) throws DeviceManagementException;
boolean updateEnrollment(String owner, List<String> deviceIdentifiers) boolean updateEnrollment(String owner, List<String> deviceIdentifiers)
throws DeviceManagementException, UserNotFoundException, InvalidDeviceException; throws DeviceManagementException, UserNotFoundException, InvalidDeviceException;

@ -3038,8 +3038,7 @@ public class DeviceManagementProviderServiceImpl implements DeviceManagementProv
} }
@Override @Override
public int getDeviceCountOfTypeByStatus(String deviceType, String deviceStatus) throws DeviceManagementException { public int getDeviceCountOfTypeByStatus(int tenantId, String deviceType, String deviceStatus) throws DeviceManagementException {
int tenantId = this.getTenantId();
try { try {
DeviceManagementDAOFactory.openConnection(); DeviceManagementDAOFactory.openConnection();
return deviceDAO.getDeviceCount(deviceType, deviceStatus, tenantId); return deviceDAO.getDeviceCount(deviceType, deviceStatus, tenantId);
@ -3057,8 +3056,7 @@ public class DeviceManagementProviderServiceImpl implements DeviceManagementProv
} }
@Override @Override
public List<String> getDeviceIdentifiersByStatus(String deviceType, String deviceStatus) throws DeviceManagementException { public List<String> getDeviceIdentifiersByStatus(int tenantId, String deviceType, String deviceStatus) throws DeviceManagementException {
int tenantId = this.getTenantId();
List<String> deviceIds; List<String> deviceIds;
try { try {
DeviceManagementDAOFactory.openConnection(); DeviceManagementDAOFactory.openConnection();
@ -3078,8 +3076,9 @@ public class DeviceManagementProviderServiceImpl implements DeviceManagementProv
} }
@Override @Override
public boolean bulkUpdateDeviceStatus(String deviceType, List<String> deviceList, String status) throws DeviceManagementException { public boolean bulkUpdateDeviceStatus(int tenantId, String deviceType,
int tenantId = this.getTenantId(); List<String> deviceList, String status)
throws DeviceManagementException {
boolean success; boolean success;
try { try {
DeviceManagementDAOFactory.openConnection(); DeviceManagementDAOFactory.openConnection();

@ -606,7 +606,7 @@ public final class DeviceManagerUtil {
*/ */
@SuppressWarnings("PackageAccessibility") @SuppressWarnings("PackageAccessibility")
public static AppRegistrationCredentials getApplicationRegistrationCredentials(String host, String port, public static AppRegistrationCredentials getApplicationRegistrationCredentials(String host, String port,
String credentials) String username, String password)
throws ApplicationRegistrationException { throws ApplicationRegistrationException {
if (host == null || port == null) { if (host == null || port == null) {
String msg = "Required gatewayHost or gatewayPort system property is null"; String msg = "Required gatewayHost or gatewayPort system property is null";
@ -622,7 +622,7 @@ public final class DeviceManagerUtil {
apiEndpoint.setHeader(HTTP.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString()); apiEndpoint.setHeader(HTTP.CONTENT_TYPE, ContentType.APPLICATION_JSON.toString());
apiEndpoint.setHeader(DeviceManagementConstants.ConfigurationManagement.AUTHORIZATION_HEADER, apiEndpoint.setHeader(DeviceManagementConstants.ConfigurationManagement.AUTHORIZATION_HEADER,
DeviceManagementConstants.ConfigurationManagement.BASIC_AUTH.concat(" ") DeviceManagementConstants.ConfigurationManagement.BASIC_AUTH.concat(" ")
.concat(getBase64EncodedCredentials(credentials))); .concat(getBase64EncodedCredentials(username + ":" + password)));
apiEndpoint.setEntity(constructApplicationRegistrationPayload()); apiEndpoint.setEntity(constructApplicationRegistrationPayload());
HttpResponse response = client.execute(apiEndpoint); HttpResponse response = client.execute(apiEndpoint);
if (response != null) { if (response != null) {

@ -98,14 +98,15 @@
<div class="row"> <div class="row">
<div class="dontfloat feature-wrapper" name ="deviceFeature"> <div class="dontfloat feature-wrapper" name ="deviceFeature">
<div class="col-xs-3"> <div class="col-xs-3">
<input type="text" class="form-control" id="feature-name" placeholder="name" value="{{this.name}}"/> <input type="text" class="form-control feature-name" placeholder="name" value="{{this.name}}"/>
</div> </div>
<div class="col-xs-4"> <div class="col-xs-4">
<input type="text" class="form-control" id="feature-code" placeholder="code" value="{{this.code}}"/> <input type="text" class="form-control feature-code" placeholder="code" value="{{this.code}}"/>
</div> </div>
<div class="col-xs-4"> <div class="col-xs-4">
<textarea aria-describedby="basic-addon1" type="text" id="feature-description" <textarea aria-describedby="basic-addon1" type="text"
placeholder="description" data-error-msg="invalid feature description" class="form-control" rows="1" cols="30">{{this.description}}</textarea> placeholder="description" data-error-msg="invalid feature description"
class="form-control feature-description" rows="1" cols="30">{{this.description}}</textarea>
</div> </div>
<button type="button" class="wr-btn wr-btn-horizontal remove_feature_button"><i class="fa fa-minus"></i></button> <button type="button" class="wr-btn wr-btn-horizontal remove_feature_button"><i class="fa fa-minus"></i></button>
</div> </div>
@ -115,16 +116,16 @@
<div class="row"> <div class="row">
<div class="dontfloat feature-wrapper" name="deviceFeature"> <div class="dontfloat feature-wrapper" name="deviceFeature">
<div class="col-xs-3"> <div class="col-xs-3">
<input type="text" class="form-control" id="feature-name" placeholder="name"/> <input type="text" class="form-control feature-name" placeholder="name"/>
</div> </div>
<div class="col-xs-4"> <div class="col-xs-4">
<input type="text" class="form-control" id="feature-code" placeholder="code"/> <input type="text" class="form-control feature-code" placeholder="code"/>
</div> </div>
<div class="col-xs-4"> <div class="col-xs-4">
<textarea aria-describedby="basic-addon1" type="text" id="feature-description" <textarea aria-describedby="basic-addon1" type="text"
placeholder="description" placeholder="description"
data-error-msg="invalid feature description" data-error-msg="invalid feature description"
class="form-control" rows="1" cols="30"></textarea> class="form-control feature-description" rows="1" cols="30"></textarea>
</div> </div>
<button type="button" class="wr-btn wr-btn-horizontal add_feature_button"><i class="fa fa-plus"></i></button> <button type="button" class="wr-btn wr-btn-horizontal add_feature_button"><i class="fa fa-plus"></i></button>
</div> </div>
@ -221,7 +222,7 @@
</div> </div>
<br> <br>
<button id="add-devicetype-btn" class="wr-btn">Update</button> <button id="edit-devicetype-btn" class="wr-btn">Update</button>
<div id="devicetype-create-success-msg" class="alert hidden" role="alert"> <div id="devicetype-create-success-msg" class="alert hidden" role="alert">
<i class="icon fw fw-success"></i><span></span> <i class="icon fw fw-success"></i><span></span>
</div> </div>

@ -123,27 +123,28 @@ $(document).ready(function () {
var addFeatureButton = $('.add_feature_button'); //Add button selector var addFeatureButton = $('.add_feature_button'); //Add button selector
var featureWrapper = $('.feature_field_wrapper'); //Input field wrapper var featureWrapper = $('.feature_field_wrapper'); //Input field wrapper
$(addFeatureButton).click(function(){ //Once add button is clicked $(addFeatureButton).click(function(){ //Once add button is clicked
var featureFieldHtml = '<div class="row"><div class="dontfloat feature-wrapper" name ="deviceFeature"> <div class="col-xs-3"> <input type="text"' + var featureFieldHtml = '<div class="row"><div class="dontfloat feature-wrapper" name ="deviceFeature"> ' +
' class="form-control" id="feature-name" placeholder="name"/> </div> <div class="col-xs-4"> ' + '<div class="col-xs-3"> <input type="text"' +
'<input type="text" class="form-control" id="feature-code" placeholder="code"/> </div> ' + ' class="form-control feature-name" placeholder="name"/> </div> <div class="col-xs-4"> ' +
'<input type="text" class="form-control feature-code" placeholder="code"/> </div> ' +
'<div class="col-xs-4"> <textarea aria-describedby="basic-addon1" type="text" ' + '<div class="col-xs-4"> <textarea aria-describedby="basic-addon1" type="text" ' +
'id="feature-description" placeholder="description"data-error-msg="invalid ' + 'class="feature-description" placeholder="description"data-error-msg="invalid ' +
'feature description" class="form-control" rows="1" cols="30"></textarea> </div> ' + 'feature description" class="form-control" rows="1" cols="30"></textarea> </div> ' +
'<button type="button" class="wr-btn wr-btn-horizontal wr-btn-secondary remove_feature_button"><i class="fa fa-minus"></i></button> </div></div>' '<button type="button" class="wr-btn wr-btn-horizontal wr-btn-secondary remove_feature_button">' +
'<i class="fa fa-minus"></i></button> </div></div>';
$(featureWrapper).append(featureFieldHtml); // Add field html $(featureWrapper).append(featureFieldHtml); // Add field html
}); });
$(featureWrapper).on('click', '.remove_feature_button', function(e){ //Once remove button is clicked $(featureWrapper).on('click', '.remove_feature_button', function(e){ //Once remove button is clicked
e.preventDefault(); e.preventDefault();
$(this).parent('div').remove(); //Remove field html $(this).parent('div').remove(); //Remove field html
op--; //Decrement field counter
}); });
/** /**
* Following click function would execute * Following click function would execute
* when a user clicks on "Add Device type" button. * when a user clicks on "Add Device type" button.
*/ */
$("button#add-devicetype-btn").click(function () { $("button#edit-devicetype-btn").click(function () {
var errorMsgWrapper = "#devicetype-create-error-msg"; var errorMsgWrapper = "#devicetype-create-error-msg";
var errorMsg = "#devicetype-create-error-msg span"; var errorMsg = "#devicetype-create-error-msg span";
@ -155,13 +156,19 @@ $(document).ready(function () {
if (!deviceTypeName || deviceTypeName.trim() == "" ) { if (!deviceTypeName || deviceTypeName.trim() == "" ) {
$(errorMsg).text("Device Type Name Cannot be empty."); $(errorMsg).text("Device Type Name Cannot be empty.");
$(errorMsgWrapper).removeClass("hidden"); $(errorMsgWrapper).removeClass("hidden");
$([document.documentElement, document.body]).animate({
scrollTop: $(".page-sub-title").offset().top
}, 500);
return; return;
} }
if (!deviceTypeDescription || deviceTypeDescription.trim() == "") { if (!deviceTypeDescription || deviceTypeDescription.trim() == "") {
$(errorMsg).text("Device Type Description Cannot be empty."); $(errorMsg).text("Device Type Description Cannot be empty.");
$(errorMsgWrapper).removeClass("hidden"); $(errorMsgWrapper).removeClass("hidden");
return $([document.documentElement, document.body]).animate({
scrollTop: $(".page-sub-title").offset().top
}, 500);
return;
} }
deviceType.name = deviceTypeName.trim(); deviceType.name = deviceTypeName.trim();
@ -197,17 +204,29 @@ $(document).ready(function () {
} }
var features = []; var features = [];
var featureCodesValidation = true;
var regexp = /^[a-zA-Z0-9-_]+$/;
$('div[name^="deviceFeature"]').each(function() { $('div[name^="deviceFeature"]').each(function() {
var featureName = $(this).find("#feature-name").val(); var featureName = $(this).find(".feature-name").val();
var featureCode = $(this).find("#feature-code").val(); var featureCode = $(this).find(".feature-code").val();
var featureDescription = $(this).find(".feature-description").val();
if (featureName && featureName.trim() != "" && featureCode && featureCode.trim() != "") { if (featureName && featureName.trim() != "" && featureCode && featureCode.trim() != "") {
featureCodesValidation = featureCodesValidation && (featureCode.search(regexp) != -1);
var feature = {}; var feature = {};
feature.name = featureName.trim(); feature.name = featureName.trim();
feature.code = featureCode.trim(); feature.code = featureCode.trim();
feature.description = $("#feature-description").val(); feature.description = featureDescription;
features.push(feature); features.push(feature);
} }
}); });
if (!featureCodesValidation) {
$(errorMsg).text("Device Type feature code can only contain alphanumeric, underscore and dash characters.");
$(errorMsgWrapper).removeClass("hidden");
$([document.documentElement, document.body]).animate({
scrollTop: $(".page-sub-title").offset().top
}, 500);
return;
}
deviceType.deviceTypeMetaDefinition.features = features; deviceType.deviceTypeMetaDefinition.features = features;
var addRoleAPI = apiBasePath + "/admin/device-types/" + deviceType.name; var addRoleAPI = apiBasePath + "/admin/device-types/" + deviceType.name;
@ -217,6 +236,7 @@ $(document).ready(function () {
deviceType, deviceType,
function (data, textStatus, jqXHR) { function (data, textStatus, jqXHR) {
if (jqXHR.status == 200) { if (jqXHR.status == 200) {
$(errorMsgIdentifier).addClass(" hidden");
$("#modalDevice").modal('show'); $("#modalDevice").modal('show');
} }
}, },

@ -40,4 +40,5 @@
{{#zone "content"}} {{#zone "content"}}
{{unit "cdmf.unit.device.operation-mod"}} {{unit "cdmf.unit.device.operation-mod"}}
{{unit "cdmf.unit.effective-policy.view"}} {{unit "cdmf.unit.effective-policy.view"}}
{{unit "cdmf.unit.lib.data-table"}}
{{/zone}} {{/zone}}

@ -77,24 +77,25 @@ var displayPolicy = function (policyPayloadObj) {
'.policy-view/css/' + deviceType + '-policy-view.css'; '.policy-view/css/' + deviceType + '-policy-view.css';
var policyOperationsTemplateCacheKey = deviceType + '-policy-operations'; var policyOperationsTemplateCacheKey = deviceType + '-policy-operations';
$.isResourceExists(policyOperationsTemplateSrc, function (status) { if (policyOperationsTemplateSrc) {
$.template(policyOperationsTemplateCacheKey, policyOperationsTemplateSrc, function (template) { if (policyOperationsScriptSrc) {
var content = template();
$("#device-type-policy-operations").html(content).removeClass("hidden");
$(".policy-platform").addClass("hidden");
$.isResourceExists(policyOperationsScriptSrc, function (status) {
var script = document.createElement('script'); var script = document.createElement('script');
script.type = 'text/javascript'; script.type = 'text/javascript';
script.src = policyOperationsScriptSrc; script.src = policyOperationsScriptSrc;
$(".wr-advance-operations").prepend(script); $(".wr-advance-operations").prepend(script);
}
$.template(policyOperationsTemplateCacheKey, policyOperationsTemplateSrc, function (template) {
var content = template();
$("#device-type-policy-operations").html(content).removeClass("hidden");
$(".policy-platform").addClass("hidden");
if (policyOperationsScriptSrc) {
/* /*
This method should be implemented in the relevant plugin side and should include the logic to This method should be implemented in the relevant plugin side and should include the logic to
populate the policy profile in the plugin specific UI. populate the policy profile in the plugin specific UI.
*/ */
polulateProfileOperations(policyPayloadObj["profile"]["profileFeaturesList"]); polulateProfileOperations(policyPayloadObj["profile"]["profileFeaturesList"]);
}
}); });
});
$.isResourceExists(policyOperationsStylesSrc, function (status) { $.isResourceExists(policyOperationsStylesSrc, function (status) {
var style = document.createElement('link'); var style = document.createElement('link');
style.type = 'text/css'; style.type = 'text/css';
@ -104,7 +105,7 @@ var displayPolicy = function (policyPayloadObj) {
}); });
$(".wr-advance-operations-init").addClass("hidden"); $(".wr-advance-operations-init").addClass("hidden");
}); }
}; };
/** /**

Loading…
Cancel
Save