KEYCLOAK-6503: Linked Accounts Page

This commit is contained in:
Stan Silvert 2019-11-13 16:53:31 -05:00 committed by Bruno Oliveira da Silva
parent 2d3f771b70
commit d439f4181a
6 changed files with 296 additions and 25 deletions

View file

@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
*/ */
public class LinkedAccountRepresentation implements Comparable<LinkedAccountRepresentation> { public class LinkedAccountRepresentation implements Comparable<LinkedAccountRepresentation> {
private boolean connected; private boolean connected;
private boolean isSocial;
private String providerAlias; private String providerAlias;
private String providerName; private String providerName;
private String displayName; private String displayName;
@ -47,6 +48,14 @@ public class LinkedAccountRepresentation implements Comparable<LinkedAccountRepr
public void setConnected(boolean connected) { public void setConnected(boolean connected) {
this.connected = connected; this.connected = connected;
} }
public boolean isSocial() {
return this.isSocial;
}
public void setSocial(boolean isSocial) {
this.isSocial = isSocial;
}
public String getProviderAlias() { public String getProviderAlias() {
return providerAlias; return providerAlias;

View file

@ -19,6 +19,7 @@ package org.keycloak.services.resources.account;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.SortedSet; import java.util.SortedSet;
@ -35,6 +36,7 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.credential.CredentialModel; import org.keycloak.credential.CredentialModel;
import org.keycloak.events.Details; import org.keycloak.events.Details;
@ -48,6 +50,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.account.AccountLinkUriRepresentation; import org.keycloak.representations.account.AccountLinkUriRepresentation;
import org.keycloak.representations.account.LinkedAccountRepresentation; import org.keycloak.representations.account.LinkedAccountRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
@ -96,6 +99,16 @@ public class LinkedAccountsResource {
SortedSet<LinkedAccountRepresentation> linkedAccounts = getLinkedAccounts(this.session, this.realm, this.user); SortedSet<LinkedAccountRepresentation> linkedAccounts = getLinkedAccounts(this.session, this.realm, this.user);
return Cors.add(request, Response.ok(linkedAccounts)).auth().allowedOrigins(auth.getToken()).build(); return Cors.add(request, Response.ok(linkedAccounts)).auth().allowedOrigins(auth.getToken()).build();
} }
private Set<String> findSocialIds() {
Set<String> socialIds = new HashSet();
List<ProviderFactory> providerFactories = session.getKeycloakSessionFactory().getProviderFactories(SocialIdentityProvider.class);
for (ProviderFactory factory: providerFactories) {
socialIds.add(factory.getId());
}
return socialIds;
}
public SortedSet<LinkedAccountRepresentation> getLinkedAccounts(KeycloakSession session, RealmModel realm, UserModel user) { public SortedSet<LinkedAccountRepresentation> getLinkedAccounts(KeycloakSession session, RealmModel realm, UserModel user) {
List<IdentityProviderModel> identityProviders = realm.getIdentityProviders(); List<IdentityProviderModel> identityProviders = realm.getIdentityProviders();
@ -103,6 +116,7 @@ public class LinkedAccountsResource {
if (identityProviders == null || identityProviders.isEmpty()) return linkedAccounts; if (identityProviders == null || identityProviders.isEmpty()) return linkedAccounts;
Set<String> socialIds = findSocialIds();
Set<FederatedIdentityModel> identities = session.users().getFederatedIdentities(user, realm); Set<FederatedIdentityModel> identities = session.users().getFederatedIdentities(user, realm);
for (IdentityProviderModel provider : identityProviders) { for (IdentityProviderModel provider : identityProviders) {
if (!provider.isEnabled()) { if (!provider.isEnabled()) {
@ -117,6 +131,7 @@ public class LinkedAccountsResource {
LinkedAccountRepresentation rep = new LinkedAccountRepresentation(); LinkedAccountRepresentation rep = new LinkedAccountRepresentation();
rep.setConnected(identity != null); rep.setConnected(identity != null);
rep.setSocial(socialIds.contains(provider.getProviderId()));
rep.setProviderAlias(providerId); rep.setProviderAlias(providerId);
rep.setDisplayName(displayName); rep.setDisplayName(displayName);
rep.setGuiOrder(guiOrder); rep.setGuiOrder(guiOrder);

View file

@ -39,4 +39,17 @@ add=Add
shareSuccess=Resource successfully shared. shareSuccess=Resource successfully shared.
resourceAlreadyShared=Resource is already shared with this user. resourceAlreadyShared=Resource is already shared with this user.
resourceNotShared=This resource is not shared. resourceNotShared=This resource is not shared.
permissionRequests=Permission Requests permissionRequests=Permission Requests
# Linked Accounts Page
linkedAccountsTitle=Linked Accounts
linkedAccountsIntroMessage=Manage logins through third-party accounts.
linkedLoginProviders=Linked Login Providers
unlinkedLoginProviders=Unlinked Login Providers
linkedEmpty=No Linked Providers
unlinkedEmpty=No Unlinked Providers
socialLogin=Social Login
systemDefined=System Defined
link=Link Account
unLink=Unlink Account

View file

@ -1,5 +1,5 @@
/* /*
* Copyright 2018 Red Hat, Inc. and/or its affiliates. * Copyright 2019 Red Hat, Inc. and/or its affiliates.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,21 +15,213 @@
*/ */
import * as React from 'react'; import * as React from 'react';
import {withRouter, RouteComponentProps} from 'react-router-dom';
export interface LinkedAccountsPageProps { import {AxiosResponse} from 'axios';
import {
Badge,
Button,
DataList,
DataListAction,
DataListItemCells,
DataListCell,
DataListItemRow,
Stack,
StackItem,
Title,
TitleLevel,
DataListItem,
} from '@patternfly/react-core';
import {
BitbucketIcon,
CubeIcon,
FacebookIcon,
GithubIcon,
GitlabIcon,
GoogleIcon,
InstagramIcon,
LinkIcon,
LinkedinIcon,
MicrosoftIcon,
OpenshiftIcon,
PaypalIcon,
StackOverflowIcon,
TwitterIcon,
UnlinkIcon
} from '@patternfly/react-icons';
import {AccountServiceClient} from '../../account-service/account.service';
import {Msg} from '../../widgets/Msg';
import {ContentPage} from '../ContentPage';
import {createRedirect} from '../../util/RedirectUri';
interface LinkedAccount {
connected: boolean;
social: boolean;
providerAlias: string;
providerName: string;
displayName: string;
linkedUsername: string;
} }
export class LinkedAccountsPage extends React.Component<LinkedAccountsPageProps> { interface LinkedAccountsPageProps extends RouteComponentProps {
}
interface LinkedAccountsPageState {
linkedAccounts: LinkedAccount[];
unLinkedAccounts: LinkedAccount[];
}
/**
* @author Stan Silvert
*/
class LinkedAccountsPage extends React.Component<LinkedAccountsPageProps, LinkedAccountsPageState> {
public constructor(props: LinkedAccountsPageProps) { public constructor(props: LinkedAccountsPageProps) {
super(props); super(props);
this.state = {
linkedAccounts: [],
unLinkedAccounts: []
}
this.getLinkedAccounts();
}
private getLinkedAccounts(): void {
AccountServiceClient.Instance.doGet("/linked-accounts")
.then((response: AxiosResponse<LinkedAccount[]>) => {
console.log({response});
const linkedAccounts = response.data.filter((account) => account.connected);
const unLinkedAccounts = response.data.filter((account) => !account.connected);
this.setState({linkedAccounts: linkedAccounts, unLinkedAccounts: unLinkedAccounts});
});
}
private unLinkAccount(account: LinkedAccount): void {
const url = '/linked-accounts/' + account.providerName;
AccountServiceClient.Instance.doDelete(url)
.then((response: AxiosResponse) => {
console.log({response});
this.getLinkedAccounts();
});
}
private linkAccount(account: LinkedAccount): void {
const url = '/linked-accounts/' + account.providerName;
const redirectUri: string = createRedirect(this.props.location.pathname);
AccountServiceClient.Instance.doGet(url, { params: {providerId: account.providerName, redirectUri}})
.then((response: AxiosResponse<{accountLinkUri: string}>) => {
console.log({response});
window.location.href = response.data.accountLinkUri;
});
} }
public render(): React.ReactNode { public render(): React.ReactNode {
return ( return (
<div> <ContentPage title={Msg.localize('linkedAccountsTitle')} introMessage={Msg.localize('linkedAccountsIntroMessage')}>
<h2>Hello Linked Accounts Page</h2> <Stack gutter='md'>
</div> <StackItem isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<Msg msgKey='linkedLoginProviders'/>
</Title>
<DataList aria-label='foo'>
{this.makeRows(this.state.linkedAccounts, true)}
</DataList>
</StackItem>
<StackItem isFilled/>
<StackItem isFilled>
<Title headingLevel={TitleLevel.h2} size='2xl'>
<Msg msgKey='unlinkedLoginProviders'/>
</Title>
<DataList aria-label='foo'>
{this.makeRows(this.state.unLinkedAccounts, false)}
</DataList>
</StackItem>
</Stack>
</ContentPage>
); );
} }
};
private emptyRow(isLinked: boolean): React.ReactNode {
let isEmptyMessage = '';
if (isLinked) {
isEmptyMessage = Msg.localize('linkedEmpty');
} else {
isEmptyMessage = Msg.localize('unlinkedEmpty');
}
return (
<DataListItem key='emptyItem' aria-labelledby="empty-item">
<DataListItemRow key='emptyRow'>
<DataListItemCells dataListCells={[
<DataListCell key='empty'><strong>{isEmptyMessage}</strong></DataListCell>
]}/>
</DataListItemRow>
</DataListItem>
)
}
private makeRows(accounts: LinkedAccount[], isLinked: boolean): React.ReactNode {
if (accounts.length === 0) {
return this.emptyRow(isLinked);
}
return (
<> {
accounts.map( (account: LinkedAccount) => (
<DataListItem key={account.providerName} aria-labelledby="simple-item1">
<DataListItemRow key={account.providerName}>
<DataListItemCells
dataListCells={[
<DataListCell key='idp'><Stack><StackItem isFilled>{this.findIcon(account)}</StackItem><StackItem isFilled><h2><strong>{account.displayName}</strong></h2></StackItem></Stack></DataListCell>,
<DataListCell key='badge'><Stack><StackItem isFilled/><StackItem isFilled>{this.badge(account)}</StackItem></Stack></DataListCell>,
<DataListCell key='username'><Stack><StackItem isFilled/><StackItem isFilled>{account.linkedUsername}</StackItem></Stack></DataListCell>,
]}/>
<DataListAction aria-labelledby='foo' aria-label='foo action' id='setPasswordAction'>
{isLinked && <Button variant='link' onClick={() => this.unLinkAccount(account)}><UnlinkIcon size='sm'/> <Msg msgKey='unLink'/></Button>}
{!isLinked && <Button variant='link' onClick={() => this.linkAccount(account)}><LinkIcon size='sm'/> <Msg msgKey='link'/></Button>}
</DataListAction>
</DataListItemRow>
</DataListItem>
))
} </>
)
}
private badge(account: LinkedAccount): React.ReactNode {
if (account.social) {
return (<Badge><Msg msgKey='socialLogin'/></Badge>);
}
return (<Badge style={{backgroundColor: "green"}} ><Msg msgKey='systemDefined'/></Badge>);
}
private findIcon(account: LinkedAccount): React.ReactNode {
if (account.providerName.toLowerCase().includes('github')) return (<GithubIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('linkedin')) return (<LinkedinIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('facebook')) return (<FacebookIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('google')) return (<GoogleIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('instagram')) return (<InstagramIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('microsoft')) return (<MicrosoftIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('bitbucket')) return (<BitbucketIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('twitter')) return (<TwitterIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('openshift')) return (<OpenshiftIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('gitlab')) return (<GitlabIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('paypal')) return (<PaypalIcon size='xl'/>);
if (account.providerName.toLowerCase().includes('stackoverflow')) return (<StackOverflowIcon size='xl'/>);
return (<CubeIcon size='xl'/>);
}
};
const LinkedAccountsPagewithRouter = withRouter(LinkedAccountsPage);
export {LinkedAccountsPagewithRouter as LinkedAccountsPage};

View file

@ -0,0 +1,42 @@
/*
* Copyright 2019 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.
*/
declare const baseUrl: string;
declare const referrer: string;
declare const referrerUri: string;
/**
* Create a redirect uri that can return to this application with referrer and referrer_uri intact.
*
* @param currentLocation The ReactRouter location to return to.
*
* @author Stan Silvert
*/
export const createRedirect = (currentLocation: string): string => {
let redirectUri: string = baseUrl;
if (typeof referrer !== 'undefined') {
// '_hash_' is a workaround for when uri encoding is not
// sufficient to escape the # character properly.
// The problem is that both the redirect and the application URL contain a hash.
// The browser will consider anything after the first hash to be client-side. So
// it sees the hash in the redirect param and stops.
redirectUri += "?referrer=" + referrer + "&referrer_uri=" + referrerUri.replace('#', '_hash_');
}
return encodeURIComponent(redirectUri) + encodeURIComponent("/#" + currentLocation);
}

View file

@ -318,7 +318,7 @@
'./icons/binoculars-icon.js': '@empty', './icons/binoculars-icon.js': '@empty',
'./icons/biohazard-icon.js': '@empty', './icons/biohazard-icon.js': '@empty',
'./icons/birthday-cake-icon.js': '@empty', './icons/birthday-cake-icon.js': '@empty',
'./icons/bitbucket-icon.js': '@empty', //'./icons/bitbucket-icon.js': '@empty',
'./icons/bitcoin-icon.js': '@empty', './icons/bitcoin-icon.js': '@empty',
'./icons/bity-icon.js': '@empty', './icons/bity-icon.js': '@empty',
'./icons/black-tie-icon.js': '@empty', './icons/black-tie-icon.js': '@empty',
@ -543,7 +543,7 @@
'./icons/crutch-icon.js': '@empty', './icons/crutch-icon.js': '@empty',
'./icons/css3-alt-icon.js': '@empty', './icons/css3-alt-icon.js': '@empty',
'./icons/css3-icon.js': '@empty', './icons/css3-icon.js': '@empty',
'./icons/cube-icon.js': '@empty', //'./icons/cube-icon.js': '@empty',
'./icons/cubes-icon.js': '@empty', './icons/cubes-icon.js': '@empty',
'./icons/cut-icon.js': '@empty', './icons/cut-icon.js': '@empty',
'./icons/cuttlefish-icon.js': '@empty', './icons/cuttlefish-icon.js': '@empty',
@ -656,7 +656,7 @@
'./icons/eye-icon.js': '@empty', './icons/eye-icon.js': '@empty',
'./icons/eye-slash-icon.js': '@empty', './icons/eye-slash-icon.js': '@empty',
'./icons/facebook-f-icon.js': '@empty', './icons/facebook-f-icon.js': '@empty',
'./icons/facebook-icon.js': '@empty', //'./icons/facebook-icon.js': '@empty',
'./icons/facebook-messenger-icon.js': '@empty', './icons/facebook-messenger-icon.js': '@empty',
'./icons/facebook-square-icon.js': '@empty', './icons/facebook-square-icon.js': '@empty',
'./icons/fantasy-flight-games-icon.js': '@empty', './icons/fantasy-flight-games-icon.js': '@empty',
@ -763,10 +763,10 @@
'./icons/git-icon.js': '@empty', './icons/git-icon.js': '@empty',
'./icons/git-square-icon.js': '@empty', './icons/git-square-icon.js': '@empty',
'./icons/github-alt-icon.js': '@empty', './icons/github-alt-icon.js': '@empty',
'./icons/github-icon.js': '@empty', //'./icons/github-icon.js': '@empty',
'./icons/github-square-icon.js': '@empty', './icons/github-square-icon.js': '@empty',
'./icons/gitkraken-icon.js': '@empty', './icons/gitkraken-icon.js': '@empty',
'./icons/gitlab-icon.js': '@empty', //'./icons/gitlab-icon.js': '@empty',
'./icons/gitter-icon.js': '@empty', './icons/gitter-icon.js': '@empty',
'./icons/glass-cheers-icon.js': '@empty', './icons/glass-cheers-icon.js': '@empty',
'./icons/glass-martini-alt-icon.js': '@empty', './icons/glass-martini-alt-icon.js': '@empty',
@ -786,7 +786,7 @@
'./icons/goodreads-g-icon.js': '@empty', './icons/goodreads-g-icon.js': '@empty',
'./icons/goodreads-icon.js': '@empty', './icons/goodreads-icon.js': '@empty',
'./icons/google-drive-icon.js': '@empty', './icons/google-drive-icon.js': '@empty',
'./icons/google-icon.js': '@empty', //'./icons/google-icon.js': '@empty',
'./icons/google-play-icon.js': '@empty', './icons/google-play-icon.js': '@empty',
'./icons/google-plus-g-icon.js': '@empty', './icons/google-plus-g-icon.js': '@empty',
'./icons/google-plus-icon.js': '@empty', './icons/google-plus-icon.js': '@empty',
@ -910,7 +910,7 @@
//'./icons/info-circle-icon.js': '@empty', //'./icons/info-circle-icon.js': '@empty',
'./icons/info-icon.js': '@empty', './icons/info-icon.js': '@empty',
'./icons/infrastructure-icon.js': '@empty', './icons/infrastructure-icon.js': '@empty',
'./icons/instagram-icon.js': '@empty', //'./icons/instagram-icon.js': '@empty',
'./icons/integration-icon.js': '@empty', './icons/integration-icon.js': '@empty',
'./icons/intercom-icon.js': '@empty', './icons/intercom-icon.js': '@empty',
//'./icons/internet-explorer-icon.js': '@empty', //'./icons/internet-explorer-icon.js': '@empty',
@ -970,8 +970,8 @@
'./icons/life-ring-icon.js': '@empty', './icons/life-ring-icon.js': '@empty',
'./icons/lightbulb-icon.js': '@empty', './icons/lightbulb-icon.js': '@empty',
'./icons/line-icon.js': '@empty', './icons/line-icon.js': '@empty',
'./icons/link-icon.js': '@empty', //'./icons/link-icon.js': '@empty',
'./icons/linkedin-icon.js': '@empty', //'./icons/linkedin-icon.js': '@empty',
'./icons/linkedin-in-icon.js': '@empty', './icons/linkedin-in-icon.js': '@empty',
'./icons/linode-icon.js': '@empty', './icons/linode-icon.js': '@empty',
'./icons/linux-icon.js': '@empty', './icons/linux-icon.js': '@empty',
@ -1040,7 +1040,7 @@
'./icons/microphone-icon.js': '@empty', './icons/microphone-icon.js': '@empty',
'./icons/microphone-slash-icon.js': '@empty', './icons/microphone-slash-icon.js': '@empty',
'./icons/microscope-icon.js': '@empty', './icons/microscope-icon.js': '@empty',
'./icons/microsoft-icon.js': '@empty', //'./icons/microsoft-icon.js': '@empty',
'./icons/middleware-icon.js': '@empty', './icons/middleware-icon.js': '@empty',
'./icons/migration-icon.js': '@empty', './icons/migration-icon.js': '@empty',
'./icons/minus-circle-icon.js': '@empty', './icons/minus-circle-icon.js': '@empty',
@ -1098,7 +1098,7 @@
'./icons/on-running-icon.js': '@empty', './icons/on-running-icon.js': '@empty',
'./icons/opencart-icon.js': '@empty', './icons/opencart-icon.js': '@empty',
'./icons/openid-icon.js': '@empty', './icons/openid-icon.js': '@empty',
'./icons/openshift-icon.js': '@empty', //'./icons/openshift-icon.js': '@empty',
//'./icons/opera-icon.js': '@empty', //'./icons/opera-icon.js': '@empty',
'./icons/optimize-icon.js': '@empty', './icons/optimize-icon.js': '@empty',
'./icons/optin-monster-icon.js': '@empty', './icons/optin-monster-icon.js': '@empty',
@ -1281,7 +1281,7 @@
'./icons/pause-icon.js': '@empty', './icons/pause-icon.js': '@empty',
'./icons/paused-icon.js': '@empty', './icons/paused-icon.js': '@empty',
'./icons/paw-icon.js': '@empty', './icons/paw-icon.js': '@empty',
'./icons/paypal-icon.js': '@empty', //'./icons/paypal-icon.js': '@empty',
'./icons/peace-icon.js': '@empty', './icons/peace-icon.js': '@empty',
'./icons/pen-alt-icon.js': '@empty', './icons/pen-alt-icon.js': '@empty',
'./icons/pen-fancy-icon.js': '@empty', './icons/pen-fancy-icon.js': '@empty',
@ -1549,7 +1549,7 @@
'./icons/square-root-alt-icon.js': '@empty', './icons/square-root-alt-icon.js': '@empty',
'./icons/squarespace-icon.js': '@empty', './icons/squarespace-icon.js': '@empty',
'./icons/stack-exchange-icon.js': '@empty', './icons/stack-exchange-icon.js': '@empty',
'./icons/stack-overflow-icon.js': '@empty', //'./icons/stack-overflow-icon.js': '@empty',
'./icons/stackpath-icon.js': '@empty', './icons/stackpath-icon.js': '@empty',
'./icons/stamp-icon.js': '@empty', './icons/stamp-icon.js': '@empty',
'./icons/star-and-crescent-icon.js': '@empty', './icons/star-and-crescent-icon.js': '@empty',
@ -1688,7 +1688,7 @@
'./icons/tumblr-square-icon.js': '@empty', './icons/tumblr-square-icon.js': '@empty',
'./icons/tv-icon.js': '@empty', './icons/tv-icon.js': '@empty',
'./icons/twitch-icon.js': '@empty', './icons/twitch-icon.js': '@empty',
'./icons/twitter-icon.js': '@empty', //'./icons/twitter-icon.js': '@empty',
'./icons/twitter-square-icon.js': '@empty', './icons/twitter-square-icon.js': '@empty',
'./icons/typo3-icon.js': '@empty', './icons/typo3-icon.js': '@empty',
'./icons/uber-icon.js': '@empty', './icons/uber-icon.js': '@empty',
@ -1703,7 +1703,7 @@
'./icons/universal-access-icon.js': '@empty', './icons/universal-access-icon.js': '@empty',
'./icons/university-icon.js': '@empty', './icons/university-icon.js': '@empty',
'./icons/unknown-icon.js': '@empty', './icons/unknown-icon.js': '@empty',
'./icons/unlink-icon.js': '@empty', //'./icons/unlink-icon.js': '@empty',
'./icons/unlock-alt-icon.js': '@empty', './icons/unlock-alt-icon.js': '@empty',
'./icons/unlock-icon.js': '@empty', './icons/unlock-icon.js': '@empty',
'./icons/unlocked-icon.js': '@empty', './icons/unlocked-icon.js': '@empty',