admin console

This commit is contained in:
Bill Burke 2018-01-27 13:05:02 -05:00
parent dd4c0d448c
commit 1d8e38f0c6
18 changed files with 282 additions and 35 deletions

View file

@ -296,17 +296,48 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
return getUserSessions(realm, client, firstResult, maxResults, false);
}
@Override
public List<UserSessionModel> getOfflineUserSessions(RealmModel realm) {
return getOfflineUserSessions(realm, -1, -1);
}
@Override
public List<UserSessionModel> getOfflineUserSessions(RealmModel realm, int first, int max) {
return getUserSessions(realm, first, max, true);
}
protected List<UserSessionModel> getUserSessions(final RealmModel realm, ClientModel client, int firstResult, int maxResults, final boolean offline) {
final String clientUuid = client.getId();
UserSessionPredicate predicate = UserSessionPredicate.create(realm.getId()).client(clientUuid);
return getUserSessionModels(realm, firstResult, maxResults, offline, predicate);
}
@Override
public List<UserSessionModel> getUserSessions(RealmModel realm) {
return getUserSessions(realm, -1, -1);
}
@Override
public List<UserSessionModel> getUserSessions(RealmModel realm, int firstResult, int maxResults) {
return getUserSessions(realm, firstResult, maxResults, false);
}
protected List<UserSessionModel> getUserSessions(final RealmModel realm, int firstResult, int maxResults, final boolean offline) {
UserSessionPredicate predicate = UserSessionPredicate.create(realm.getId());
return getUserSessionModels(realm, firstResult, maxResults, offline, predicate);
}
protected List<UserSessionModel> getUserSessionModels(RealmModel realm, int firstResult, int maxResults, boolean offline, UserSessionPredicate predicate) {
Cache<String, SessionEntityWrapper<UserSessionEntity>> cache = getCache(offline);
cache = CacheDecorators.skipCacheLoaders(cache);
Cache<UUID, SessionEntityWrapper<AuthenticatedClientSessionEntity>> clientSessionCache = getClientSessionCache(offline);
Cache<UUID, SessionEntityWrapper<AuthenticatedClientSessionEntity>> clientSessionCacheDecorated = CacheDecorators.skipCacheLoaders(clientSessionCache);
final String clientUuid = client.getId();
Stream<UserSessionEntity> stream = cache.entrySet().stream()
.filter(UserSessionPredicate.create(realm.getId()).client(clientUuid))
.filter(predicate)
.map(Mappers.userSessionEntity())
.sorted(Comparators.userSessionLastSessionRefresh());
@ -330,7 +361,6 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
return sessions;
}
@Override
public UserSessionModel getUserSessionWithPredicate(RealmModel realm, String id, boolean offline, Predicate<UserSessionModel> predicate) {
UserSessionModel userSession = getUserSession(realm, id, offline);

View file

@ -27,12 +27,12 @@ import java.util.Set;
* @version $Revision: 1 $
*/
public interface ClientProvider extends ClientLookupProvider, Provider {
List<ClientModel> getClients(RealmModel realm);
ClientModel addClient(RealmModel realm, String clientId);
ClientModel addClient(RealmModel realm, String id, String clientId);
List<ClientModel> getClients(RealmModel realm);
RoleModel addClientRole(RealmModel realm, ClientModel client, String name);
RoleModel addClientRole(RealmModel realm, ClientModel client, String id, String name);

View file

@ -35,9 +35,11 @@ public interface UserSessionProvider extends Provider {
UserSessionModel createUserSession(RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId);
UserSessionModel createUserSession(String id, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId);
UserSessionModel getUserSession(RealmModel realm, String id);
List<UserSessionModel> getUserSessions(RealmModel realm);
List<UserSessionModel> getUserSessions(RealmModel realm, UserModel user);
List<UserSessionModel> getUserSessions(RealmModel realm, ClientModel client);
List<UserSessionModel> getUserSessions(RealmModel realm, ClientModel client, int firstResult, int maxResults);
List<UserSessionModel> getUserSessions(RealmModel realm, int firstResult, int maxResults);
List<UserSessionModel> getUserSessionByBrokerUserId(RealmModel realm, String brokerUserId);
UserSessionModel getUserSessionByBrokerSessionId(RealmModel realm, String brokerSessionId);
@ -75,9 +77,11 @@ public interface UserSessionProvider extends Provider {
/** Will automatically attach newly created offline client session to the offlineUserSession **/
AuthenticatedClientSessionModel createOfflineClientSession(AuthenticatedClientSessionModel clientSession, UserSessionModel offlineUserSession);
List<UserSessionModel> getOfflineUserSessions(RealmModel realm, UserModel user);
List<UserSessionModel> getOfflineUserSessions(RealmModel realm);
long getOfflineSessionsCount(RealmModel realm, ClientModel client);
List<UserSessionModel> getOfflineUserSessions(RealmModel realm, ClientModel client, int first, int max);
List<UserSessionModel> getOfflineUserSessions(RealmModel realm, int first, int max);
/** Triggered by persister during pre-load. It optionally imports authenticatedClientSessions too if requested. Otherwise the imported UserSession will have empty list of AuthenticationSessionModel **/
UserSessionModel importUserSession(UserSessionModel persistentUserSession, boolean offline, boolean importAuthenticatedClientSessions);

View file

@ -25,6 +25,9 @@ import org.keycloak.storage.client.ClientLookupProvider;
/**
* Base interface for components that want to provide an alternative storage mechanism for clients
*
* This is currently a private incomplete SPI. Please discuss on dev list if you want us to complete it or want to do the work yourself.
* This work is described in KEYCLOAK-6408 JIRA issue.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/

View file

@ -0,0 +1,111 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.resources.admin;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.NotFoundException;
import org.keycloak.common.ClientConnection;
import org.keycloak.component.ComponentModel;
import org.keycloak.events.admin.OperationType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.UserStorageSyncManager;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderModel;
import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.ldap.LDAPStorageProvider;
import org.keycloak.storage.ldap.mappers.LDAPStorageMapper;
import org.keycloak.storage.user.SynchronizationResult;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.UriInfo;
import java.util.HashMap;
import java.util.Map;
/**
* @resource User Storage Provider
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ClientStorageProviderResource {
private static final Logger logger = Logger.getLogger(ClientStorageProviderResource.class);
protected RealmModel realm;
protected AdminPermissionEvaluator auth;
protected AdminEventBuilder adminEvent;
@Context
protected ClientConnection clientConnection;
@Context
protected UriInfo uriInfo;
@Context
protected KeycloakSession session;
@Context
protected HttpHeaders headers;
public ClientStorageProviderResource(RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
this.auth = auth;
this.realm = realm;
this.adminEvent = adminEvent;
}
/**
* Need this for admin console to display simple name of provider when displaying client detail
*
* KEYCLOAK-4328
*
* @param id
* @return
*/
@GET
@Path("{id}/name")
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public Map<String, String> getSimpleName(@PathParam("id") String id) {
auth.clients().requireList();
ComponentModel model = realm.getComponent(id);
if (model == null) {
throw new NotFoundException("Could not find component");
}
if (!model.getProviderType().equals(ClientStorageProvider.class.getName())) {
throw new NotFoundException("found, but not a ClientStorageProvider");
}
Map<String, String> data = new HashMap<>();
data.put("id", model.getId());
data.put("name", model.getName());
return data;
}
}

View file

@ -98,7 +98,7 @@ public class ClientsResource {
public List<ClientRepresentation> getClients(@QueryParam("clientId") String clientId, @QueryParam("viewableOnly") @DefaultValue("false") boolean viewableOnly) {
List<ClientRepresentation> rep = new ArrayList<>();
if (clientId == null) {
if (clientId == null || clientId.trim().equals("")) {
List<ClientModel> clientModels = realm.getClients();
auth.clients().requireList();
boolean view = auth.clients().canView();

View file

@ -100,6 +100,7 @@ import java.security.cert.X509Certificate;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
@ -504,17 +505,62 @@ public class RealmAdminResource {
public List<Map<String, String>> getClientSessionStats() {
auth.realm().requireViewRealm();
List<Map<String, String>> data = new LinkedList<Map<String, String>>();
for (ClientModel client : realm.getClients()) {
long size = session.sessions().getActiveUserSessions(client.getRealm(), client);
if (size == 0) continue;
Map<String, String> map = new HashMap<>();
map.put("id", client.getId());
map.put("clientId", client.getClientId());
map.put("active", size + "");
data.add(map);
Map<String, Map<String, String>> data = new HashMap();
{
List<UserSessionModel> userSessions = session.sessions().getUserSessions(realm);
Map<String, Long> activeCount = new HashMap<>();
// we have to iterate over all realm user sessions as clients coming from client storage provider might not be reachable from getClients()
for (UserSessionModel userSession : userSessions) {
for (String id : userSession.getAuthenticatedClientSessions().keySet()) {
Long number = activeCount.get(id);
if (number == null) {
activeCount.put(id, new Long(1));
} else {
activeCount.put(id, number + 1);
}
}
}
for (Map.Entry<String, Long> entry : activeCount.entrySet()) {
Map<String, String> map = new HashMap<>();
ClientModel client = realm.getClientById(entry.getKey());
map.put("id", client.getId());
map.put("clientId", client.getClientId());
map.put("active", entry.getValue().toString());
map.put("offline", "0");
data.put(client.getId(), map);
}
}
return data;
{
Map<String, Long> offlineCount = new HashMap<>();
// we have to iterate over all realm user sessions as clients coming from client storage provider might not be reachable from getClients()
List<UserSessionModel> offlineSessions = session.sessions().getOfflineUserSessions(realm);
for (UserSessionModel userSession : offlineSessions) {
for (String id : userSession.getAuthenticatedClientSessions().keySet()) {
Long number = offlineCount.get(id);
if (number == null) {
offlineCount.put(id, new Long(1));
} else {
offlineCount.put(id, number + 1);
}
}
}
for (Map.Entry<String, Long> entry : offlineCount.entrySet()) {
Map<String, String> map = data.get(entry.getKey());
if (map == null) {
map = new HashMap<>();
ClientModel client = realm.getClientById(entry.getKey());
map.put("id", client.getId());
map.put("clientId", client.getClientId());
map.put("active", "0");
data.put(client.getId(), map);
}
map.put("offline", entry.getValue().toString());
}
}
List<Map<String, String>> result = new LinkedList<>();
for (Map<String, String> item : data.values()) result.add(item);
return result;
}
/**

View file

@ -31,6 +31,7 @@ import org.keycloak.models.ClientTemplateModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.ForbiddenException;
import org.keycloak.storage.StorageId;
import java.util.Arrays;
import java.util.Collection;
@ -634,8 +635,8 @@ class ClientPermissions implements ClientPermissionEvaluator, ClientPermissionM
public Map<String, Boolean> getAccess(ClientModel client) {
Map<String, Boolean> map = new HashMap<>();
map.put("view", canView(client));
map.put("manage", canManage(client));
map.put("configure", canConfigure(client));
map.put("manage", StorageId.isLocalStorage(client) && canManage(client));
map.put("configure", StorageId.isLocalStorage(client) && canConfigure(client));
return map;
}

View file

@ -25,14 +25,13 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.storage.client.ClientLookupProvider;
import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.client.ClientStorageProviderFactory;
import org.keycloak.storage.client.ClientStorageProviderModel;
import org.keycloak.storage.user.UserLookupProvider;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
@ -163,9 +162,12 @@ public class ClientStorageManager implements ClientProvider {
return session.clientLocalStorage().addClient(realm, id, clientId);
}
@Override
public List<ClientModel> getClients(RealmModel realm) {
return session.clientLocalStorage().getClients(realm);
return session.clientLocalStorage().getClients(realm);
}
@Override

View file

@ -23,7 +23,6 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.client.AbstractClientStorageAdapter;
import org.keycloak.storage.client.AbstractReadOnlyClientStorageAdapter;
import org.keycloak.storage.client.ClientLookupProvider;
import org.keycloak.storage.client.ClientStorageProvider;
@ -55,13 +54,13 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl
public ClientModel getClientById(String id, RealmModel realm) {
StorageId storageId = new StorageId(id);
final String clientId = storageId.getExternalId();
if (clientId.equals(clientId)) return new ClientAdapter(realm);
if (this.clientId.equals(clientId)) return new ClientAdapter(realm);
return null;
}
@Override
public ClientModel getClientByClientId(String clientId, RealmModel realm) {
if (clientId.equals(clientId)) return new ClientAdapter(realm);
if (this.clientId.equals(clientId)) return new ClientAdapter(realm);
return null;
}
@ -155,7 +154,7 @@ public class HardcodedClientStorageProvider implements ClientStorageProvider, Cl
@Override
public String getProtocol() {
return null;
return "openid-connect";
}
@Override

View file

@ -132,6 +132,7 @@ public class ClientStorageTest extends AbstractTestRealmKeycloakTest {
public void testBrowser() throws Exception {
String clientId = "hardcoded-client";
testBrowser(clientId);
//Thread.sleep(10000000);
}
private void testBrowser(String clientId) {

View file

@ -173,6 +173,7 @@ realm-sessions=Realm Sessions
revocation=Revocation
logout-all=Logout all
active-sessions=Active Sessions
offline-sessions=Offline Sessions
sessions=Sessions
not-before=Not Before
not-before.tooltip=Revoke any tokens issued before this date.
@ -1343,6 +1344,8 @@ userStorage.cachePolicy.maxLifespan.tooltip=Max lifespan of a user cache entry i
user-origin-link=Storage Origin
user-origin.tooltip=UserStorageProvider the user was loaded from
user-link.tooltip=UserStorageProvider this locally stored user was imported from.
client-origin-link=Storage Origin
client-origin.tooltip=Provider the client was loaded from
disable=Disable
disableable-credential-types=Disableable Types

View file

@ -764,6 +764,15 @@ module.controller('ClientListCtrl', function($scope, realm, Client, serverInfo,
});
};
$scope.searchClient = function() {
console.log('searchQuery!!! ' + $scope.search.clientId);
Client.query({realm: realm.realm, viewableOnly: true, clientId: $scope.search.clientId}).$promise.then(function(clients) {
$scope.numberOfPages = Math.ceil(clients.length/$scope.pageSize);
$scope.clients = clients;
});
};
$scope.exportClient = function(client) {
var clientCopy = angular.copy(client);
delete clientCopy.id;
@ -819,7 +828,7 @@ module.controller('ClientInstallationCtrl', function($scope, realm, client, serv
}
});
module.controller('ClientDetailCtrl', function($scope, realm, client, templates, $route, serverInfo, Client, ClientDescriptionConverter, $location, $modal, Dialog, Notifications) {
module.controller('ClientDetailCtrl', function($scope, realm, client, templates, $route, serverInfo, Client, ClientDescriptionConverter, Components, ClientStorageOperations, $location, $modal, Dialog, Notifications) {
@ -889,6 +898,25 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient;
if(client.origin) {
if ($scope.access.viewRealm) {
Components.get({realm: realm.realm, componentId: client.origin}, function (link) {
$scope.originName = link.name;
//$scope.originLink = "#/realms/" + realm.realm + "/user-storage/providers/" + link.providerId + "/" + link.id;
})
}
else {
// KEYCLOAK-4328
ClientStorageOperations.simpleName.get({realm: realm.realm, componentId: client.origin}, function (link) {
$scope.originName = link.name;
//$scope.originLink = $location.absUrl();
})
}
} else {
console.log("origin is null");
}
function updateProperties() {
if (!$scope.client.attributes) {
$scope.client.attributes = {};

View file

@ -1813,6 +1813,16 @@ module.factory('UserStorageOperations', function($resource) {
});
module.factory('ClientStorageOperations', function($resource) {
var object = {}
object.simpleName = $resource(authUrl + '/admin/realms/:realm/client-storage/:componentId/name', {
realm : '@realm',
componentId : '@componentId'
});
return object;
});
module.factory('ClientRegistrationPolicyProviders', function($resource) {
return $resource(authUrl + '/admin/realms/:realm/client-registration-policy/providers', {
realm : '@realm',

View file

@ -37,6 +37,13 @@
</div>
<kc-tooltip>{{:: 'client.enabled.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="client.origin">
<label class="col-md-2 control-label">{{:: 'client-origin-link' | translate}}</label>
<div class="col-md-6">
{{originName}}
</div>
<kc-tooltip>{{:: 'client-origin.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol != 'docker-v2'">
<label class="col-md-2 control-label" for="consentRequired">{{:: 'consent-required' | translate}}</label>
<div class="col-sm-6">

View file

@ -11,9 +11,9 @@
<div class="form-inline">
<div class="form-group">
<div class="input-group">
<input type="text" placeholder="{{:: 'search.placeholder' | translate}}" data-ng-model="search.clientId" class="form-control search" onkeyup="if(event.keyCode === 13){$(this).next('I').click();}">
<input type="text" placeholder="{{:: 'search.placeholder' | translate}}" data-ng-model="search.clientId" class="form-control search" onkeydown="if(event.keyCode === 13) document.getElementById('clientSearch').click()">
<div class="input-group-addon">
<i class="fa fa-search" type="submit"></i>
<i class="fa fa-search" id="clientSearch" data-ng-click="searchClient()"></i>
</div>
</div>
</div>

View file

@ -18,12 +18,14 @@
<tr>
<th>{{:: 'client' | translate}}</th>
<th>{{:: 'active-sessions' | translate}}</th>
<th>{{:: 'offline-sessions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr data-ng-repeat="data in stats">
<td><a href="#/realms/{{realm.realm}}/clients/{{data.id}}/sessions">{{data.clientId}}</a></td>
<td>{{data.active}}</td>
<td>{{data.offline}}</td>
</tr>
</tbody>
</table>

View file

@ -9,11 +9,11 @@
<ul class="nav nav-tabs" data-ng-hide="create && !path[4]">
<li ng-class="{active: !path[4]}"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}">{{:: 'settings' | translate}}</a></li>
<li ng-class="{active: path[4] == 'credentials'}"
data-ng-show="!client.publicClient && client.protocol == 'openid-connect'">
data-ng-show="!client.publicClient && client.protocol == 'openid-connect' && !client.origin">
<a href="#/realms/{{realm.realm}}/clients/{{client.id}}/credentials">{{:: 'credentials' | translate}}</a>
</li>
<li ng-class="{active: path[4] == 'saml'}" data-ng-show="client.protocol == 'saml' && (client.attributes['saml.client.signature'] == 'true' || client.attributes['saml.encrypt'] == 'true')"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/saml/keys">{{:: 'saml-keys' | translate}}</a></li>
<li ng-class="{active: path[4] == 'roles'}"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/roles">{{:: 'roles' | translate}}</a></li>
<li ng-class="{active: path[4] == 'roles'}" data-ng-show="!client.origin"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/roles">{{:: 'roles' | translate}}</a></li>
<li ng-class="{active: path[4] == 'mappers'}" data-ng-show="!client.bearerOnly">
<a href="#/realms/{{realm.realm}}/clients/{{client.id}}/mappers">{{:: 'mappers' | translate}}</a>
<kc-tooltip>{{:: 'mappers.tooltip' | translate}}</kc-tooltip>
@ -23,10 +23,10 @@
<kc-tooltip>{{:: 'scope.tooltip' | translate}}</kc-tooltip>
</li>
<li ng-class="{active: path[4] == 'authz'}"
data-ng-show="serverInfo.featureEnabled('AUTHORIZATION') && !disableAuthorizationTab && client.authorizationServicesEnabled">
data-ng-show="serverInfo.featureEnabled('AUTHORIZATION') && !disableAuthorizationTab && client.authorizationServicesEnabled && !client.origin">
<a href="#/realms/{{realm.realm}}/clients/{{client.id}}/authz/resource-server">{{:: 'authz-authorization' |
translate}}</a></li>
<li ng-class="{active: path[4] == 'revocation'}" data-ng-show="client.protocol != 'docker-v2' && client.protocol != 'saml'"><a
<li ng-class="{active: path[4] == 'revocation'}" data-ng-show="client.protocol != 'docker-v2' && client.protocol != 'saml' && !client.origin"><a
href="#/realms/{{realm.realm}}/clients/{{client.id}}/revocation">{{:: 'revocation' | translate}}</a>
</li>
<!-- <li ng-class="{active: path[4] == 'identity-provider'}" data-ng-show="realm.identityFederationEnabled"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/identity-provider">Identity Provider</a></li> -->
@ -40,9 +40,9 @@
<kc-tooltip>{{:: 'offline-access.tooltip' | translate}}</kc-tooltip>
</li>
<li ng-class="{active: path[4] == 'clustering'}" data-ng-show="!client.publicClient"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/clustering">{{:: 'clustering' | translate}}</a></li>
<li ng-class="{active: path[4] == 'clustering'}" data-ng-show="!client.publicClient && !client.origin"><a href="#/realms/{{realm.realm}}/clients/{{client.id}}/clustering">{{:: 'clustering' | translate}}</a></li>
<li ng-class="{active: path[4] == 'installation'}">
<li ng-class="{active: path[4] == 'installation'}" data-ng-show="!client.origin">
<a href="#/realms/{{realm.realm}}/clients/{{client.id}}/installation">{{:: 'installation' | translate}}</a>
<kc-tooltip>{{:: 'installation.tooltip' | translate}}</kc-tooltip>
</li>