diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/UserAccountsPage.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/UserAccountsPage.java index e619b68ec..9327f9078 100644 --- a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/UserAccountsPage.java +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/UserAccountsPage.java @@ -59,8 +59,8 @@ public abstract class UserAccountsPage { userAccountsDao = wdf.getUserAccountsDao(); } - protected static boolean isEmailEnabled(HttpServletRequest req) { - return FreemarkerEmailFactory.isConfigured(req); + protected boolean isEmailEnabled() { + return FreemarkerEmailFactory.isConfigured(vreq); } protected String getStringParameter(String key, String defaultValue) { @@ -126,6 +126,7 @@ public abstract class UserAccountsPage { map.put("list", UrlBuilder.getUrl("/accountsAdmin/list")); map.put("add", UrlBuilder.getUrl("/accountsAdmin/add")); map.put("delete", UrlBuilder.getUrl("/accountsAdmin/delete")); + map.put("myAccount", UrlBuilder.getUrl("/accounts/myAccount")); map.put("createPassword", UrlBuilder.getUrl("/accounts/createPassword")); map.put("resetPassword", UrlBuilder.getUrl("/accounts/resetPassword")); diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsAddPage.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsAddPage.java index 556326c77..6dd6393a1 100644 --- a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsAddPage.java +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsAddPage.java @@ -54,7 +54,7 @@ public class UserAccountsAddPage extends UserAccountsPage { super(vreq); this.strategy = UserAccountsAddPageStrategy.getInstance(vreq, this, - isEmailEnabled(vreq)); + isEmailEnabled()); parseRequestParameters(); diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPage.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPage.java index 61f42c6a0..e7ae1a8ae 100644 --- a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPage.java +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPage.java @@ -63,7 +63,7 @@ public class UserAccountsEditPage extends UserAccountsPage { super(vreq); this.strategy = UserAccountsEditPageStrategy.getInstance(vreq, this, - isEmailEnabled(vreq)); + isEmailEnabled()); parseRequestParameters(); validateUserAccountInfo(); diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPageStrategy.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPageStrategy.java index 6de05de00..c174f8288 100644 --- a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPageStrategy.java +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/admin/UserAccountsEditPageStrategy.java @@ -13,6 +13,7 @@ import edu.cornell.mannlib.vitro.webapp.beans.UserAccount; import edu.cornell.mannlib.vitro.webapp.beans.UserAccount.Status; import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; import edu.cornell.mannlib.vitro.webapp.controller.accounts.UserAccountsPage; +import edu.cornell.mannlib.vitro.webapp.controller.authenticate.Authenticator; import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder; import edu.cornell.mannlib.vitro.webapp.email.FreemarkerEmailFactory; import edu.cornell.mannlib.vitro.webapp.email.FreemarkerEmailMessage; @@ -166,7 +167,7 @@ public abstract class UserAccountsEditPageStrategy extends UserAccountsPage { protected String additionalValidations() { if (newPassword.isEmpty() && confirmPassword.isEmpty()) { return ""; - } else if (!checkPasswordLength()) { + } else if (!checkPasswordLength(newPassword)) { return ERROR_WRONG_PASSWORD_LENGTH; } else if (!newPassword.equals(confirmPassword)) { return ERROR_PASSWORDS_DONT_MATCH; @@ -175,11 +176,6 @@ public abstract class UserAccountsEditPageStrategy extends UserAccountsPage { } } - private boolean checkPasswordLength() { - return newPassword.length() >= UserAccount.MIN_PASSWORD_LENGTH - && newPassword.length() <= UserAccount.MAX_PASSWORD_LENGTH; - } - @Override protected void addMoreBodyValues(Map body) { body.put("newPassword", newPassword); @@ -190,8 +186,10 @@ public abstract class UserAccountsEditPageStrategy extends UserAccountsPage { @Override protected void setAdditionalProperties(UserAccount u) { - u.setMd5Password(newPassword); - u.setPasswordChangeRequired(true); + if (!newPassword.isEmpty()) { + u.setMd5Password(Authenticator.applyMd5Encoding(newPassword)); + u.setPasswordChangeRequired(true); + } } @Override diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPage.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPage.java new file mode 100644 index 000000000..51b8f0a3b --- /dev/null +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPage.java @@ -0,0 +1,176 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.controller.accounts.user; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import edu.cornell.mannlib.vedit.beans.LoginStatusBean; +import edu.cornell.mannlib.vitro.webapp.beans.User; +import edu.cornell.mannlib.vitro.webapp.beans.UserAccount; +import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; +import edu.cornell.mannlib.vitro.webapp.controller.accounts.UserAccountsPage; +import edu.cornell.mannlib.vitro.webapp.controller.accounts.admin.UserAccountsEditPage; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.responsevalues.ResponseValues; +import edu.cornell.mannlib.vitro.webapp.controller.freemarker.responsevalues.TemplateResponseValues; +import edu.cornell.mannlib.vitro.webapp.dao.UserDao; +import edu.cornell.mannlib.vitro.webapp.dao.WebappDaoFactory; + +/** + * Handle the "My Account" form display and submission. + */ +public class UserAccountsMyAccountPage extends UserAccountsPage { + private static final Log log = LogFactory + .getLog(UserAccountsEditPage.class); + + private static final String PARAMETER_SUBMIT = "submitMyAccount"; + private static final String PARAMETER_EMAIL_ADDRESS = "emailAddress"; + private static final String PARAMETER_FIRST_NAME = "firstName"; + private static final String PARAMETER_LAST_NAME = "lastName"; + + private static final String ERROR_NO_EMAIL = "errorEmailIsEmpty"; + private static final String ERROR_EMAIL_IN_USE = "errorEmailInUse"; + private static final String ERROR_NO_FIRST_NAME = "errorFirstNameIsEmpty"; + private static final String ERROR_NO_LAST_NAME = "errorLastNameIsEmpty"; + + private static final String TEMPLATE_NAME = "userAccounts-myAccount.ftl"; + + private final UserAccountsMyAccountPageStrategy strategy; + + private final UserAccount userAccount; + + /* The request parameters */ + private boolean submit; + private String emailAddress = ""; + private String firstName = ""; + private String lastName = ""; + + /** The result of validating a "submit" request. */ + private String errorCode = ""; + + /** The result of updating the account. */ + private String confirmationCode = ""; + + public UserAccountsMyAccountPage(VitroRequest vreq) { + super(vreq); + + this.userAccount = getLoggedInUser(); + this.strategy = UserAccountsMyAccountPageStrategy.getInstance(vreq, + this, isExternalAccount()); + + parseRequestParameters(); + + if (isSubmit()) { + validateParameters(); + } + } + + public UserAccount getUserAccount() { + return userAccount; + } + + private void parseRequestParameters() { + submit = isFlagOnRequest(PARAMETER_SUBMIT); + emailAddress = getStringParameter(PARAMETER_EMAIL_ADDRESS, ""); + firstName = getStringParameter(PARAMETER_FIRST_NAME, ""); + lastName = getStringParameter(PARAMETER_LAST_NAME, ""); + + strategy.parseAdditionalParameters(); + } + + public boolean isSubmit() { + return submit; + } + + private void validateParameters() { + if (emailAddress.isEmpty()) { + errorCode = ERROR_NO_EMAIL; + } else if (emailIsChanged() && isEmailInUse()) { + errorCode = ERROR_EMAIL_IN_USE; + } else if (firstName.isEmpty()) { + errorCode = ERROR_NO_FIRST_NAME; + } else if (lastName.isEmpty()) { + errorCode = ERROR_NO_LAST_NAME; + } else { + errorCode = strategy.additionalValidations(); + } + } + + private boolean emailIsChanged() { + return !emailAddress.equals(userAccount.getEmailAddress()); + } + + private boolean isEmailInUse() { + return userAccountsDao.getUserAccountByEmail(emailAddress) != null; + } + + public boolean isValid() { + return errorCode.isEmpty(); + } + + private UserAccount getLoggedInUser() { + // TODO This is a bogus measure. + // TODO It only works because for now we are not deleting old User + // structures, and there is a new UserAccount with email set to the old + // User username. + String uri = LoginStatusBean.getBean(vreq).getUserURI(); + WebappDaoFactory wdf = (WebappDaoFactory) this.ctx + .getAttribute("webappDaoFactory"); + User u = wdf.getUserDao().getUserByURI(uri); + + UserAccount ua = userAccountsDao.getUserAccountByEmail(u.getUsername()); + if (ua == null) { + throw new IllegalStateException("Couldn't find a UserAccount " + + "for uri: '" + uri + "'"); + } + log.debug("Logged-in user is " + ua); + return ua; + } + + private boolean isExternalAccount() { + return LoginStatusBean.getBean(vreq).hasExternalAuthentication(); + } + + public final ResponseValues showPage() { + Map body = new HashMap(); + + if (isSubmit()) { + body.put("emailAddress", emailAddress); + body.put("firstName", firstName); + body.put("lastName", lastName); + } else { + body.put("emailAddress", userAccount.getEmailAddress()); + body.put("firstName", userAccount.getFirstName()); + body.put("lastName", userAccount.getLastName()); + } + body.put("formUrls", buildUrlsMap()); + + if (!errorCode.isEmpty()) { + body.put(errorCode, Boolean.TRUE); + } + if (!confirmationCode.isEmpty()) { + body.put(confirmationCode, Boolean.TRUE); + } + + strategy.addMoreBodyValues(body); + + return new TemplateResponseValues(TEMPLATE_NAME, body); + } + + public void updateAccount() { + userAccount.setEmailAddress(emailAddress); + userAccount.setFirstName(firstName); + userAccount.setLastName(lastName); + + strategy.setAdditionalProperties(userAccount); + + userAccountsDao.updateUserAccount(userAccount); + + strategy.notifyUser(); + confirmationCode = strategy.getConfirmationCode(); + } + +} diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPageStrategy.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPageStrategy.java new file mode 100644 index 000000000..f220d3236 --- /dev/null +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsMyAccountPageStrategy.java @@ -0,0 +1,196 @@ +/* $This file is distributed under the terms of the license in /doc/license.txt$ */ + +package edu.cornell.mannlib.vitro.webapp.controller.accounts.user; + +import static javax.mail.Message.RecipientType.TO; + +import java.util.HashMap; +import java.util.Map; + +import edu.cornell.mannlib.vitro.webapp.beans.UserAccount; +import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; +import edu.cornell.mannlib.vitro.webapp.controller.accounts.UserAccountsPage; +import edu.cornell.mannlib.vitro.webapp.controller.authenticate.Authenticator; +import edu.cornell.mannlib.vitro.webapp.email.FreemarkerEmailFactory; +import edu.cornell.mannlib.vitro.webapp.email.FreemarkerEmailMessage; + +/** + * Handle the variant details of the MyAccounts page + */ +public abstract class UserAccountsMyAccountPageStrategy extends + UserAccountsPage { + + private static final String CONFIRM_CHANGE = "confirmChange"; + private static final String CONFIRM_EMAIL_SENT = "confirmEmailSent"; + + protected final UserAccountsMyAccountPage page; + + public static UserAccountsMyAccountPageStrategy getInstance( + VitroRequest vreq, UserAccountsMyAccountPage page, + boolean externalAuth) { + if (externalAuth) { + return new ExternalAuthStrategy(vreq, page); + } else { + return new InternalAuthStrategy(vreq, page); + } + } + + protected UserAccountsMyAccountPageStrategy(VitroRequest vreq, + UserAccountsMyAccountPage page) { + super(vreq); + this.page = page; + } + + public abstract void parseAdditionalParameters(); + + public abstract String additionalValidations(); + + public abstract void addMoreBodyValues(Map body); + + public abstract void setAdditionalProperties(UserAccount userAccount); + + public abstract void notifyUser(); + + public abstract String getConfirmationCode(); + + // ---------------------------------------------------------------------- + // Strategy to use if the account used External Authentication + // ---------------------------------------------------------------------- + + private static class ExternalAuthStrategy extends + UserAccountsMyAccountPageStrategy { + + ExternalAuthStrategy(VitroRequest vreq, UserAccountsMyAccountPage page) { + super(vreq, page); + } + + @Override + public void parseAdditionalParameters() { + // No additional parameters + } + + @Override + public String additionalValidations() { + // No additional validations + return ""; + } + + @Override + public void addMoreBodyValues(Map body) { + body.put("externalAuth", Boolean.TRUE); + } + + @Override + public void setAdditionalProperties(UserAccount userAccount) { + // No additional properties. + } + + @Override + public void notifyUser() { + // No notification beyond the screen message. + } + + @Override + public String getConfirmationCode() { + return CONFIRM_CHANGE; + } + } + + // ---------------------------------------------------------------------- + // Strategy to use if the account used Internal Authentication + // ---------------------------------------------------------------------- + + private static class InternalAuthStrategy extends + UserAccountsMyAccountPageStrategy { + private static final String PARAMETER_NEW_PASSWORD = "newPassword"; + private static final String PARAMETER_CONFIRM_PASSWORD = "confirmPassword"; + + private static final String ERROR_WRONG_PASSWORD_LENGTH = "errorPasswordIsWrongLength"; + private static final String ERROR_PASSWORDS_DONT_MATCH = "errorPasswordsDontMatch"; + + private final String originalEmail; + + private String newPassword; + private String confirmPassword; + private boolean emailSent; + + InternalAuthStrategy(VitroRequest vreq, UserAccountsMyAccountPage page) { + super(vreq, page); + originalEmail = page.getUserAccount().getEmailAddress(); + } + + @Override + public void parseAdditionalParameters() { + newPassword = getStringParameter(PARAMETER_NEW_PASSWORD, ""); + confirmPassword = getStringParameter(PARAMETER_CONFIRM_PASSWORD, ""); + } + + @Override + public String additionalValidations() { + if (newPassword.isEmpty() && confirmPassword.isEmpty()) { + return ""; + } else if (!newPassword.equals(confirmPassword)) { + return ERROR_PASSWORDS_DONT_MATCH; + } else if (!checkPasswordLength(newPassword)) { + return ERROR_WRONG_PASSWORD_LENGTH; + } else { + return ""; + } + } + + @Override + public void addMoreBodyValues(Map body) { + body.put("newPassword", newPassword); + body.put("confirmPassword", confirmPassword); + body.put("minimumLength", UserAccount.MIN_PASSWORD_LENGTH); + body.put("maximumLength", UserAccount.MAX_PASSWORD_LENGTH); + } + + @Override + public void setAdditionalProperties(UserAccount userAccount) { + if (!newPassword.isEmpty()) { + userAccount.setMd5Password(Authenticator + .applyMd5Encoding(newPassword)); + userAccount.setPasswordChangeRequired(false); + userAccount.setPasswordLinkExpires(0L); + } + } + + @Override + public void notifyUser() { + if (!isEmailEnabled()) { + return; + } + if (!emailHasChanged()) { + return; + } + + Map body = new HashMap(); + body.put("userAccount", page.getUserAccount()); + body.put("subjectLine", "Your VIVO email account has been changed."); + + FreemarkerEmailMessage email = FreemarkerEmailFactory + .createNewMessage(vreq); + email.addRecipient(TO, page.getUserAccount().getEmailAddress()); + email.setSubject("Your VIVO email account has been changed."); + email.setHtmlTemplate("userAccounts-confirmEmailChangedEmail-html.ftl"); + email.setTextTemplate("userAccounts-confirmEmailChangedEmail-text.ftl"); + email.setBodyMap(body); + email.send(); + + emailSent = true; + } + + private boolean emailHasChanged() { + return !page.getUserAccount().getEmailAddress() + .equals(originalEmail); + } + + @Override + public String getConfirmationCode() { + return emailSent ? CONFIRM_EMAIL_SENT : CONFIRM_CHANGE; + } + + } + +} diff --git a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsUserController.java b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsUserController.java index 8b17be386..75ba9ec72 100644 --- a/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsUserController.java +++ b/webapp/src/edu/cornell/mannlib/vitro/webapp/controller/accounts/user/UserAccountsUserController.java @@ -6,6 +6,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.Actions; +import edu.cornell.mannlib.vitro.webapp.auth.requestedAction.usepages.EditOwnAccount; import edu.cornell.mannlib.vitro.webapp.beans.DisplayMessage; import edu.cornell.mannlib.vitro.webapp.controller.VitroRequest; import edu.cornell.mannlib.vitro.webapp.controller.freemarker.FreemarkerHttpServlet; @@ -23,10 +24,17 @@ public class UserAccountsUserController extends FreemarkerHttpServlet { private static final String ACTION_CREATE_PASSWORD = "/createPassword"; private static final String ACTION_RESET_PASSWORD = "/resetPassword"; + private static final String ACTION_MY_ACCOUNT = "/myAccount"; @Override protected Actions requiredActions(VitroRequest vreq) { - return Actions.AUTHORIZED; + String action = vreq.getPathInfo(); + + if (ACTION_MY_ACCOUNT.equals(action)) { + return new Actions(new EditOwnAccount()); + } else { + return Actions.AUTHORIZED; + } } @Override @@ -38,7 +46,9 @@ public class UserAccountsUserController extends FreemarkerHttpServlet { String action = vreq.getPathInfo(); log.debug("action = '" + action + "'"); - if (ACTION_CREATE_PASSWORD.equals(action)) { + if (ACTION_MY_ACCOUNT.equals(action)) { + return handleMyAccountRequest(vreq); + } else if (ACTION_CREATE_PASSWORD.equals(action)) { return handleCreatePasswordRequest(vreq); } else if (ACTION_RESET_PASSWORD.equals(action)) { return handleResetPasswordRequest(vreq); @@ -47,6 +57,14 @@ public class UserAccountsUserController extends FreemarkerHttpServlet { } } + private ResponseValues handleMyAccountRequest(VitroRequest vreq) { + UserAccountsMyAccountPage page = new UserAccountsMyAccountPage(vreq); + if (page.isSubmit() && page.isValid()) { + page.updateAccount(); + } + return page.showPage(); + } + private ResponseValues handleCreatePasswordRequest(VitroRequest vreq) { UserAccountsCreatePasswordPage page = new UserAccountsCreatePasswordPage( vreq); diff --git a/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-html.ftl b/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-html.ftl new file mode 100644 index 000000000..e06f7e1ae --- /dev/null +++ b/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-html.ftl @@ -0,0 +1,23 @@ +<#-- $This file is distributed under the terms of the license in /doc/license.txt$ --> + +<#-- Confirmation that the user has changed his email account. --> + + + + ${subjectLine} + + +

+ Hi, ${userAccount.firstName} ${userAccount.lastName} +

+ +

+ You recently changed the email address associated with + ${userAccount.firstName} ${userAccount.lastName} +

+ +

+ Thank you. +

+ + \ No newline at end of file diff --git a/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-text.ftl b/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-text.ftl new file mode 100644 index 000000000..78f1457bf --- /dev/null +++ b/webapp/web/templates/freemarker/body/accounts/userAccounts-confirmEmailChangedEmail-text.ftl @@ -0,0 +1,10 @@ +<#-- $This file is distributed under the terms of the license in /doc/license.txt$ --> + +<#-- Confirmation that the user has changed his email account. --> + +Hi, ${userAccount.firstName} ${userAccount.lastName} + +You recently changed the email address associated with +${userAccount.firstName} ${userAccount.lastName} + +Thank you. diff --git a/webapp/web/templates/freemarker/body/accounts/userAccounts-myAccount.ftl b/webapp/web/templates/freemarker/body/accounts/userAccounts-myAccount.ftl new file mode 100644 index 000000000..534936c03 --- /dev/null +++ b/webapp/web/templates/freemarker/body/accounts/userAccounts-myAccount.ftl @@ -0,0 +1,92 @@ +<#-- $This file is distributed under the terms of the license in /doc/license.txt$ --> + +<#-- Template for editing a user account --> + +

Edit account

+ + <#if errorEmailIsEmpty??> + <#assign errorMessage = "You must supply an email address." /> + + + <#if errorEmailInUse??> + <#assign errorMessage = "An account with that email address already exists." /> + + + <#if errorFirstNameIsEmpty??> + <#assign errorMessage = "You must supply a first name." /> + + + <#if errorLastNameIsEmpty??> + <#assign errorMessage = "You must supply a last name." /> + + + <#if errorPasswordIsEmpty??> + <#assign errorMessage = "No password supplied." /> + + + <#if errorPasswordIsWrongLength??> + <#assign errorMessage = "Password must be between ${minimumLength} and ${maximumLength} characters." /> + + + <#if errorPasswordsDontMatch??> + <#assign errorMessage = "Passwords do not match." /> + + + <#if errorMessage?has_content> + + + + + <#if confirmChange??> + <#assign confirmMessage = "Your changes have been saved." /> + + + <#if confirmEmailSent??> + <#assign confirmMessage = "Your changes have been saved. A confirmation email has been sent to ${emailAddress}." /> + + + <#if confirmMessage?has_content> + + + +
+
+ My account + +
+ + + +

Note: if email changes, a confirmation email will be sent to the new email address entered above.

+ + + + + + + + <#if !externalAuth??> + + + +

Minimum of ${minimumLength} characters in length.

+

Leaving this blank means that the password will not be changed.

+ + + + + + + +

* required fields

+
+
+
+ +${stylesheets.add('')} \ No newline at end of file