This is an automated email from the git hooks/post-receive script. New commit to branch feature/28_avatars in repository pollen. See https://gitlab.nuiton.org/chorem/pollen.git commit d1e7140e8550cec3c833305d93de37a8e085768d Author: Kevin Morin <morin@codelutin.com> Date: Tue Sep 26 10:55:28 2017 +0200 refs #28 forumlaire d'upload de l'avatar --- .../pollen/rest/api/v1/PollenResourceApi.java | 8 -- .../chorem/pollen/rest/api/v1/PollenUserApi.java | 35 +++++++- .../services/service/PollenResourceService.java | 2 +- .../services/service/PollenServiceSupport.java | 4 + .../pollen/services/service/PollenUserService.java | 26 +++++- .../pollen/services/service/SocialAuthService.java | 7 +- .../services/service/security/SecurityService.java | 10 +++ pollen-ui-riot-js/src/main/web/i18n/en.json | 2 +- pollen-ui-riot-js/src/main/web/i18n/fr.json | 4 +- pollen-ui-riot-js/src/main/web/js/FetchService.js | 1 - pollen-ui-riot-js/src/main/web/js/UserService.js | 17 +++- pollen-ui-riot-js/src/main/web/tag/Pollen.tag.html | 2 +- .../src/main/web/tag/PollenHeader.tag.html | 3 +- .../src/main/web/tag/UserProfile.tag.html | 95 ++++++++++++++++------ 14 files changed, 164 insertions(+), 52 deletions(-) diff --git a/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenResourceApi.java b/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenResourceApi.java index 3cb4f974..7decb52b 100644 --- a/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenResourceApi.java +++ b/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenResourceApi.java @@ -22,7 +22,6 @@ package org.chorem.pollen.rest.api.v1; */ import org.chorem.pollen.persistence.entity.PollenResource; -import org.chorem.pollen.persistence.entity.PollenUser; import org.chorem.pollen.rest.api.beans.Resource64Bean; import org.chorem.pollen.services.bean.PollenEntityId; import org.chorem.pollen.services.bean.PollenEntityRef; @@ -131,11 +130,4 @@ public class PollenResourceApi { pollenResourceService.deleteResource(resourceId.getEntityId()); } - @Path("/avatar/{userId}") - @GET - public Response getUserAvatar(@Context PollenResourceService pollenResourceService, - @PathParam("userId") PollenEntityId<PollenUser> userId) { - ResourceStreamBean resource = pollenResourceService.getAvatar(userId.getEntityId()); - return Response.ok(resource.getResourceContent(), resource.getContentType()).build(); - } } diff --git a/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenUserApi.java b/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenUserApi.java index 4e6662d8..d3740082 100644 --- a/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenUserApi.java +++ b/pollen-rest-api/src/main/java/org/chorem/pollen/rest/api/v1/PollenUserApi.java @@ -23,6 +23,7 @@ package org.chorem.pollen.rest.api.v1; import com.google.gson.Gson; import org.brickred.socialauth.SocialAuthManager; +import org.chorem.pollen.persistence.entity.PollenResource; import org.chorem.pollen.persistence.entity.PollenUser; import org.chorem.pollen.persistence.entity.UserCredential; import org.chorem.pollen.rest.api.beans.ChangePasswordBean; @@ -31,11 +32,15 @@ import org.chorem.pollen.services.bean.PaginationResultBean; import org.chorem.pollen.services.bean.PollenEntityId; import org.chorem.pollen.services.bean.PollenEntityRef; import org.chorem.pollen.services.bean.PollenUserBean; +import org.chorem.pollen.services.bean.resource.ResourceFileBean; +import org.chorem.pollen.services.bean.resource.ResourceStreamBean; import org.chorem.pollen.services.service.InvalidFormException; +import org.chorem.pollen.services.service.PollenResourceService; import org.chorem.pollen.services.service.PollenUserService; import org.chorem.pollen.services.service.SocialAuthService; import org.chorem.pollen.services.service.security.PollenInvalidEmailActivationTokenException; import org.chorem.pollen.services.service.security.PollenSecurityContext; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.BeanParam; @@ -50,6 +55,7 @@ import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; import java.util.Map; import java.util.Objects; @@ -156,11 +162,10 @@ public class PollenUserApi { socialAuthService.deleteUserCredential(credentialId); } - @Path("/users/{userId}/avatar/{provider}") + @Path("/user/avatar/{provider}") @POST public void setAvatar(@Context SocialAuthService socialAuthService, @Context HttpServletRequest request, - @PathParam("userId") PollenEntityId<PollenUser> userId, @PathParam("provider") String provider, String providerReturn) throws Exception { @@ -170,6 +175,30 @@ public class PollenUserApi { request.getSession().removeAttribute(ApiUtils.SOCIAL_AUTH_MANAGER_SESSION_KEY); Gson gson = new Gson(); Map<String, String> paramsMap = gson.fromJson(providerReturn, Map.class); - socialAuthService.setAvatarToUser(userId, socialAuthManager, paramsMap); + socialAuthService.setAvatarToUser(socialAuthManager, paramsMap); + } + + @Path("/users/{userId}/avatar") + @GET + public Response getUserAvatar(@Context PollenResourceService pollenResourceService, + @PathParam("userId") PollenEntityId<PollenUser> userId) { + ResourceStreamBean resource = pollenResourceService.getAvatar(userId.getEntityId()); + return Response.ok(resource.getResourceContent(), resource.getContentType()).build(); + } + + @Path("/user/avatar") + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public PollenEntityRef<PollenResource> createResource(@Context PollenUserService pollenUserService, + MultipartFormDataInput input) throws InvalidFormException { + ResourceFileBean resourceBean = ApiUtils.multipartToResourceBean(input, "avatar"); + return pollenUserService.setAvatar(resourceBean); + } + + @Path("/user/avatar") + @DELETE + public void deleteUserAvatar(@Context PollenUserService pollenUserService) { + pollenUserService.deleteAvatar(); } } diff --git a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenResourceService.java b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenResourceService.java index fe4e5fc8..44b793fe 100644 --- a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenResourceService.java +++ b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenResourceService.java @@ -163,7 +163,7 @@ public class PollenResourceService extends PollenServiceSupport implements Polle return PollenEntityRef.of(savedResource); } - public PollenResource createAvatarResource(ResourceStreamBean resource) throws InvalidFormException { + public PollenResource createAvatarResource(AbstractResourceBean resource) throws InvalidFormException { checkNotNull(resource); checkIsNotPersisted(resource); diff --git a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenServiceSupport.java b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenServiceSupport.java index faa2887d..06c86f35 100644 --- a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenServiceSupport.java +++ b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenServiceSupport.java @@ -265,6 +265,10 @@ public abstract class PollenServiceSupport implements PollenService { getSecurityService().checkIsConnected(); } + public void checkIsConnected(String userId) { + getSecurityService().checkIsConnected(userId); + } + public void checkIsAdmin() { getSecurityService().checkIsAdmin(); } diff --git a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenUserService.java b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenUserService.java index 9bb0ed06..59b6e985 100644 --- a/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenUserService.java +++ b/pollen-services/src/main/java/org/chorem/pollen/services/service/PollenUserService.java @@ -26,10 +26,12 @@ import org.apache.commons.lang3.StringUtils; import org.apache.shiro.util.CollectionUtils; import org.chorem.pollen.persistence.entity.Comment; import org.chorem.pollen.persistence.entity.PollenPrincipal; +import org.chorem.pollen.persistence.entity.PollenResource; import org.chorem.pollen.persistence.entity.PollenToken; import org.chorem.pollen.persistence.entity.PollenUser; import org.chorem.pollen.persistence.entity.PollenUserTopiaDao; import org.chorem.pollen.persistence.entity.UserCredential; +import org.chorem.pollen.persistence.entity.ResourceType; import org.chorem.pollen.persistence.entity.Vote; import org.chorem.pollen.services.PollenService; import org.chorem.pollen.services.bean.PaginationParameterBean; @@ -37,6 +39,7 @@ import org.chorem.pollen.services.bean.PaginationResultBean; import org.chorem.pollen.services.bean.PollenEntityRef; import org.chorem.pollen.services.bean.PollenUserBean; import org.chorem.pollen.services.bean.UserCredentialBean; +import org.chorem.pollen.services.bean.resource.ResourceFileBean; import org.chorem.pollen.services.service.security.PollenInvalidEmailActivationTokenException; import org.chorem.pollen.services.service.security.PollenInvalidPasswordException; import org.chorem.pollen.services.service.security.PollenSecurityContext; @@ -185,7 +188,6 @@ public class PollenUserService extends PollenServiceSupport implements PollenSer PollenUser user = checkAndGetConnectedUser(); checkNotNull(newPassword); - ErrorMap errorMap = new ErrorMap(); boolean passwordNotBlank = checkNotBlank(errorMap, "newPassword", newPassword, l(getLocale(), "pollen.error.user.passwordEmpty")); @@ -266,6 +268,28 @@ public class PollenUserService extends PollenServiceSupport implements PollenSer } + public void deleteAvatar() { + checkIsConnected(); + PollenUser user = getConnectedUser(); + if (user.getAvatar() != null) { + getPollenResourceService().deleteResource(user.getAvatar().getTopiaId()); + } + commit(); + } + + public PollenEntityRef<PollenResource> setAvatar(ResourceFileBean resourceBean) throws InvalidFormException { + checkIsConnected(); + resourceBean.setResourceType(ResourceType.AVATAR); + PollenResource avatarResource = getPollenResourceService().createAvatarResource(resourceBean); + PollenUser user = getConnectedUser(); + if (user.getAvatar() != null) { + getPollenResourceService().deleteResource(user.getAvatar().getTopiaId()); + } + user.setAvatar(avatarResource); + commit(); + return PollenEntityRef.of(avatarResource); + } + protected ErrorMap checkPollenUser(PollenUserBean user) { ErrorMap errors = new ErrorMap(); diff --git a/pollen-services/src/main/java/org/chorem/pollen/services/service/SocialAuthService.java b/pollen-services/src/main/java/org/chorem/pollen/services/service/SocialAuthService.java index 38f5e17e..52c9e449 100644 --- a/pollen-services/src/main/java/org/chorem/pollen/services/service/SocialAuthService.java +++ b/pollen-services/src/main/java/org/chorem/pollen/services/service/SocialAuthService.java @@ -115,7 +115,6 @@ public class SocialAuthService extends PollenServiceSupport { Map<String, String> paramsMap) throws Exception { PollenUser connectedUser = checkAndGetConnectedUser(); - AuthProvider provider = manager.connect(paramsMap); // get profile @@ -275,13 +274,9 @@ public class SocialAuthService extends PollenServiceSupport { commit(); } - public void setAvatarToUser(PollenEntityId<PollenUser> userId, SocialAuthManager manager, Map<String, String> paramsMap) throws Exception { + public void setAvatarToUser(SocialAuthManager manager, Map<String, String> paramsMap) throws Exception { checkIsConnected(); PollenUser connectedUser = getConnectedUser(); - if (!connectedUser.getTopiaId().equals(userId.getEntityId())) { - throw new PollenUnauthorizedException(userId.getReducedId()); - } - PollenResourceService pollenResourceService = getPollenResourceService(); if (connectedUser.getAvatar() != null) { diff --git a/pollen-services/src/main/java/org/chorem/pollen/services/service/security/SecurityService.java b/pollen-services/src/main/java/org/chorem/pollen/services/service/security/SecurityService.java index f351b350..6ab376a2 100644 --- a/pollen-services/src/main/java/org/chorem/pollen/services/service/security/SecurityService.java +++ b/pollen-services/src/main/java/org/chorem/pollen/services/service/security/SecurityService.java @@ -114,6 +114,16 @@ public class SecurityService extends PollenServiceSupport { } @Override + public void checkIsConnected(String userId) { + + PollenSecurityContext securityContext = getSecurityContext(); + if (!securityContext.isConnected() || !securityContext.getPollenUser().getTopiaId().equals(userId)) { + throw new PollenUnauthorizedException("connected"); + } + + } + + @Override public void checkIsAdmin() { PollenSecurityContext securityContext = getSecurityContext(); diff --git a/pollen-ui-riot-js/src/main/web/i18n/en.json b/pollen-ui-riot-js/src/main/web/i18n/en.json index 18469062..a4a4eafc 100644 --- a/pollen-ui-riot-js/src/main/web/i18n/en.json +++ b/pollen-ui-riot-js/src/main/web/i18n/en.json @@ -417,7 +417,7 @@ "userProfile_linkProvider": "Link an external account to connect to Pollen:", "userProfile_unlinkProviderMessage": "Unlink this external account? You will not be able to connect to your Pollen account with this external account.", "userProfile_avatar": "Avatar", - "userProfile_avatarLoadingFailed": "If your avatar does not display, check that you did not enabled the tracking protection.", + "userProfile_deleteAvatarMessage": "Remove your avatar?", "choice_description_placeholder": "You can enter a description for this choice", "date-picker_today": "Today", "date-picker_dateplaceholder": "Date", diff --git a/pollen-ui-riot-js/src/main/web/i18n/fr.json b/pollen-ui-riot-js/src/main/web/i18n/fr.json index 6824e7ca..89435e5a 100644 --- a/pollen-ui-riot-js/src/main/web/i18n/fr.json +++ b/pollen-ui-riot-js/src/main/web/i18n/fr.json @@ -417,7 +417,9 @@ "userProfile_linkProvider": "Associez un compte externe pour vous connecter à Pollen :", "userProfile_unlinkProviderMessage": "Désassossier ce compte externe ? Vous ne pourrez plus vous connectez à votre compte Pollen avec ce compte externe.", "userProfile_avatar": "Avatar", - "userProfile_avatarLoadingFailed": "Si votre avatar ne s'affiche pas, vérifiez que vous n'avez pas activé la protection contre le pistage.", + "userProfile_deleteAvatarMessage": "Supprimer votre avatar ?", + "userProfile_uploadAvatar": "Téléverser un fichier", + "userProfile_getProviderAvatar": "ou utiliser votre avatar d'un service tiers", "choice_description_placeholder": "Vous pouvez saisir une description pour ce choix", "date-picker_today": "Aujourd'hui", "date-picker_dateplaceholder": "Date", diff --git a/pollen-ui-riot-js/src/main/web/js/FetchService.js b/pollen-ui-riot-js/src/main/web/js/FetchService.js index ae3de5a7..637f7e28 100644 --- a/pollen-ui-riot-js/src/main/web/js/FetchService.js +++ b/pollen-ui-riot-js/src/main/web/js/FetchService.js @@ -80,7 +80,6 @@ class FetchService { return this.fetch(url, "GET"); } - _addParamsToUrl(url, params) { if (params) { let query = "?"; diff --git a/pollen-ui-riot-js/src/main/web/js/UserService.js b/pollen-ui-riot-js/src/main/web/js/UserService.js index d1cceefa..b54ecc0f 100644 --- a/pollen-ui-riot-js/src/main/web/js/UserService.js +++ b/pollen-ui-riot-js/src/main/web/js/UserService.js @@ -82,11 +82,24 @@ class UserService extends FetchService { return this.post(url); } - getProviderAvatar(userId, query) { - let url = this._getUrlPrefix(userId) + "/avatar/" + query.loginProvider; + setProviderAvatar(query) { + let url = this._getUserUrlPrefix() + "/avatar/" + query.loginProvider; let body = JSON.stringify(query); return this.post(url, body); } + + getUserAvatarUrl(userId) { + return window.pollenConf.endPoint + this._getUsersUrlPrefix(userId) + "/avatar"; + } + + setUserAvatar(avatar) { + let url = this._getUserUrlPrefix() + "/avatar"; + return this.form(url, {avatar: avatar}, true); + } + + deleteAvatar() { + return this.doDelete(this._getUserUrlPrefix() + "/avatar"); + } } module.exports = singleton(UserService); diff --git a/pollen-ui-riot-js/src/main/web/tag/Pollen.tag.html b/pollen-ui-riot-js/src/main/web/tag/Pollen.tag.html index ef16fc1b..b192e896 100644 --- a/pollen-ui-riot-js/src/main/web/tag/Pollen.tag.html +++ b/pollen-ui-riot-js/src/main/web/tag/Pollen.tag.html @@ -308,7 +308,7 @@ require("./popup/GtuChangeModal.tag.html"); } else if (q.action === "avatar" && session.isConnected()) { let callback = (user) => { - userService.getProviderAvatar(user.id, q).then(() => { + userService.setProviderAvatar(q).then(() => { location.replace(session.pollenUIContext.uiEndPoint + "/#user/profile"); }, (e) => { e.text().then(label => { diff --git a/pollen-ui-riot-js/src/main/web/tag/PollenHeader.tag.html b/pollen-ui-riot-js/src/main/web/tag/PollenHeader.tag.html index 9712a278..e9dc0480 100644 --- a/pollen-ui-riot-js/src/main/web/tag/PollenHeader.tag.html +++ b/pollen-ui-riot-js/src/main/web/tag/PollenHeader.tag.html @@ -22,6 +22,7 @@ */ require("./HeaderI18n.tag.html"); require("./popup/FeedbackModal.tag.html"); +require("./components/LetterAvatar.tag.html"); <PollenHeader> <a class="header-home instance-title" href="#home" target="_top"></a> @@ -46,7 +47,7 @@ require("./popup/FeedbackModal.tag.html"); <div class="dropdown" if={user}> <a class="header-link"> - <i class="fa fa-user-circle"/> + <LetterAvatar name={user && user.name} rounded="true"/> <span class="user-name action-label"> {user && user.name}</span> </a> <div class="dropdown-content right"> diff --git a/pollen-ui-riot-js/src/main/web/tag/UserProfile.tag.html b/pollen-ui-riot-js/src/main/web/tag/UserProfile.tag.html index 0fcacbff..5e89f949 100644 --- a/pollen-ui-riot-js/src/main/web/tag/UserProfile.tag.html +++ b/pollen-ui-riot-js/src/main/web/tag/UserProfile.tag.html @@ -19,6 +19,7 @@ #L% --> require("./components/HumanInput.tag.html"); +require("./components/LetterAvatar.tag.html"); <UserProfile> <div class="container"> @@ -169,21 +170,32 @@ require("./components/HumanInput.tag.html"); <div class="avatar column-content"> <h3 class="c-heading"><i class="fa fa-sign-in"/> {__.avatar}</h3> - <div class="o-form-element align-center" if="{loginProviders.length > 0}"> - <p> - <!--FIXME get url from service--> - <img src="{session.configuration.endPoint}/v1/avatar/{user.id}" onerror="{avatarLoadingFailed}"/> - <i class="fa fa-question-circle cursor-help error" if="{avatarLoadingError}" title="{__.avatarLoadingFailed}"></i> - {} - </p> - <p> - <a each="{loginProvider in loginProviders}" class="provider-link" - onclick="{getProviderAvatar(loginProvider)}"> - <i class="fa fa-{authService.providerIcons[loginProvider]}" - if="{authService.providerIcons[loginProvider]}"></i> - <span if="{!authService.providerIcons[loginProvider]}">{loginProvider}</span> - </a> - </p> + <div class="avatar-container"> + <div class="avatar-column" if="{user.avatar}"> + <img src="{userService.getUserAvatarUrl(user.id)}"/> + <button onclick="{deleteAvatar}" class="c-button u-small c-button--error"><i class="fa fa-trash"></i></button> + </div> + <LetterAvatar class="avatar-column" name={user && user.name || ""} if="{!user.avatar}"/> + <div class="form-column"> + <div class="o-form-element"> + <label class="c-label" for="avatar">{__.uploadAvatar}</label> + <span class="c-input-group"> + <input type="file" class="c-field" ref="avatar" required/> + <button class="c-button" onclick="{uploadAvatar}"><i class="fa fa-upload"></i></button> + </span> + </div> + <div class="align-center" if="{loginProviders.length > 0}"> + {__.getProviderAvatar} + <div> + <a each="{loginProvider in loginProviders}" class="provider-link" + onclick="{getProviderAvatar(loginProvider)}"> + <i class="fa fa-{authService.providerIcons[loginProvider]}" + if="{authService.providerIcons[loginProvider]}"></i> + <span if="{!authService.providerIcons[loginProvider]}">{loginProvider}</span> + </a> + </div> + </div> + </div> </div> </div> </div> @@ -196,7 +208,7 @@ require("./components/HumanInput.tag.html"); this.installBundle(this.session, "userProfile"); this.errors = {}; this.user = this.session.getUser() || {}; - let userService = require("../js/UserService"); + this.userService = require("../js/UserService"); this.authService = require("../js/AuthService"); let Message = require("../js/Message"); @@ -206,17 +218,11 @@ require("./components/HumanInput.tag.html"); this.update(); }); - this.avatarLoadingError = false; - this.onUserChange = (user) => { this.user = user || {}; this.update(); }; - this.avatarLoadingFailed = (e) => { - this.avatarLoadingError = true; - } - this.resendValidation = () => { this.authService.resendValidation(this.user.email); }; @@ -226,7 +232,7 @@ require("./components/HumanInput.tag.html"); e.stopPropagation(); this.user.name = this.refs.name.value; this.user.email = this.refs.email.value; - userService.saveUser(this.user).then(() => { + this.userService.saveUser(this.user).then(() => { this.session.updateUser(); this.bus.trigger("message", new Message(this._l("updatedIdentity"), "success")); }); @@ -253,7 +259,7 @@ require("./components/HumanInput.tag.html"); if (this.errors.repeatPassword === undefined) { let oldPassword = this.user.withPassword ? this.refs.oldPassword.value : null; let newPassword = this.refs.newPassword.value; - userService.changePassword(oldPassword, newPassword).then(() => { + this.userService.changePassword(oldPassword, newPassword).then(() => { if (this.user.withPassword) { this.refs.oldPassword.value = ""; } @@ -283,14 +289,14 @@ require("./components/HumanInput.tag.html"); if (!confirm) { return Promise.reject(); } - return userService.unlinkProvider(credentialId) + return this.userService.unlinkProvider(credentialId); }).then(result => { this.user.credentials.splice(index, 1); this.update(); }); }; - getProviderAvatar= (provider) => (e) => { + this.getProviderAvatar = (provider) => (e) => { let redirection = encodeURIComponent(location.origin + location.pathname + "?loginProvider=" + provider + "&action=avatar"); this.authService.getLoginProviderUrl(provider, redirection).then(result => { @@ -298,6 +304,26 @@ require("./components/HumanInput.tag.html"); }); }; + this.deleteAvatar = e => { + this.confirm(this.__.deleteAvatarMessage).then((confirm) => { + if (!confirm) { + return Promise.reject(); + } + return this.userService.deleteAvatar(); + }).then(result => { + this.user.avatar = null; + this.update(); + }); + }; + +//TODO check size and all + this.uploadAvatar = e => { + this.userService.setUserAvatar(this.refs.avatar.files[0]).then((result) => { + this.user.avatar = result; + this.update(); + }); + } + this.listen("user", this.onUserChange); </script> @@ -331,6 +357,7 @@ require("./components/HumanInput.tag.html"); .user-credentials { width: 100%; + margin-top: 20px; } .user-credential { @@ -354,6 +381,22 @@ require("./components/HumanInput.tag.html"); font-size: 2em; } + .avatar-container { + display: flex; + flex-direction: row; + } + + .avatar-column { + display: flex; + flex-direction: column; + margin: 20px 20px 0 0; + } + + .form-column { + display: flex; + flex-direction: column; + } + </style> </UserProfile> -- To stop receiving notification emails like this one, please contact chorem.org SCM administrator <admin+scm@chorem.org>.