├── .gitignore ├── README.md ├── archetype.properties ├── license.txt ├── pom.xml └── src ├── main ├── java │ └── org │ │ └── example │ │ └── kickoff │ │ ├── business │ │ ├── BackgroundJobs.java │ │ ├── email │ │ │ ├── EmailLoader.java │ │ │ ├── EmailService.java │ │ │ ├── EmailTemplate.java │ │ │ ├── EmailTemplateService.java │ │ │ ├── EmailUser.java │ │ │ ├── Emails.java │ │ │ └── KickoffEmailService.java │ │ ├── exception │ │ │ ├── BusinessException.java │ │ │ ├── CredentialsException.java │ │ │ ├── DuplicateEntityException.java │ │ │ ├── EmailNotVerifiedException.java │ │ │ ├── EntityAlreadyModifiedException.java │ │ │ ├── EntityException.java │ │ │ ├── EntityNotFoundException.java │ │ │ ├── InvalidGroupException.java │ │ │ ├── InvalidPasswordException.java │ │ │ ├── InvalidTokenException.java │ │ │ ├── InvalidUsernameException.java │ │ │ ├── NonDeletableEntityException.java │ │ │ └── SystemException.java │ │ └── service │ │ │ ├── LoginTokenService.java │ │ │ └── PersonService.java │ │ ├── config │ │ ├── DataSourceStagedPropertiesFileLoader.java │ │ ├── InstantConverter.java │ │ ├── StartupBean.java │ │ └── auth │ │ │ ├── KickoffCallerPrincipal.java │ │ │ ├── KickoffFormAuthenticationMechanism.java │ │ │ ├── KickoffIdentityStore.java │ │ │ └── KickoffRememberMeIdentityStore.java │ │ ├── model │ │ ├── Credentials.java │ │ ├── Group.java │ │ ├── LoginToken.java │ │ ├── Person.java │ │ ├── Role.java │ │ ├── producer │ │ │ └── LoggerProducer.java │ │ └── validator │ │ │ ├── Email.java │ │ │ ├── EmailValidator.java │ │ │ ├── Password.java │ │ │ └── PasswordValidator.java │ │ └── view │ │ ├── ActiveLocale.java │ │ ├── ActiveUser.java │ │ ├── Page.java │ │ ├── admin │ │ ├── UsersBacking.java │ │ └── users │ │ │ └── EditUserBacking.java │ │ ├── auth │ │ ├── AuthBacking.java │ │ ├── LoginBacking.java │ │ ├── LogoutBacking.java │ │ ├── ResetPasswordBacking.java │ │ └── SignupBacking.java │ │ ├── composite │ │ └── InputLocalDate.java │ │ ├── converter │ │ ├── BaseEntitySelectItemsConverter.java │ │ ├── LocalDateConverter.java │ │ └── PersonConverter.java │ │ ├── filter │ │ └── LocaleFilter.java │ │ ├── phaselistener │ │ └── FacesRequestLoggerPatch.java │ │ ├── resourcehandler │ │ └── ResourceHandlerImplPatch.java │ │ ├── servlet │ │ └── ScriptErrorLogger.java │ │ ├── user │ │ └── ProfileBacking.java │ │ ├── validator │ │ ├── DuplicateEmailValidator.java │ │ └── EmailVerifiedValidator.java │ │ └── viewhandler │ │ └── LocaleAwareViewHandler.java ├── resources │ ├── META-INF │ │ ├── LoginToken.xml │ │ ├── Person.xml │ │ ├── conf │ │ │ ├── application-settings.xml │ │ │ ├── datasource-settings.xml │ │ │ ├── dev │ │ │ │ ├── application-settings.xml │ │ │ │ └── datasource-settings.xml │ │ │ ├── emails.xml │ │ │ ├── live │ │ │ │ ├── application-settings.xml │ │ │ │ └── datasource-settings.xml │ │ │ └── local-dev │ │ │ │ └── application-settings.xml │ │ ├── omni-settings.xml │ │ ├── persistence.xml │ │ └── services │ │ │ └── org.omnifaces.persistence.datasource.PropertiesFileLoader │ ├── ValidationMessages.properties │ ├── ValidationMessages_de.properties │ ├── ValidationMessages_nl.properties │ └── org │ │ └── example │ │ └── kickoff │ │ └── i18n │ │ ├── ApplicationBundle.properties │ │ ├── ApplicationBundle_de.properties │ │ ├── ApplicationBundle_en.properties │ │ └── ApplicationBundle_nl.properties └── webapp │ ├── WEB-INF │ ├── beans.xml │ ├── errorpages │ │ ├── 400.xhtml │ │ ├── 404.xhtml │ │ ├── 500.xhtml │ │ └── expired.xhtml │ ├── faces-config.xml │ ├── includes │ │ └── layout │ │ │ ├── nav-footer.xhtml │ │ │ ├── nav-header.xhtml │ │ │ ├── nav-user.xhtml │ │ │ ├── resources-body.xhtml │ │ │ └── resources-head.xhtml │ ├── kickoff.taglib.xml │ ├── tags │ │ ├── button.xhtml │ │ ├── buttons.xhtml │ │ ├── column.xhtml │ │ ├── dev.xhtml │ │ ├── form.xhtml │ │ ├── input.xhtml │ │ ├── link.xhtml │ │ └── table.xhtml │ ├── templates │ │ ├── errorpage.xhtml │ │ └── layout.xhtml │ └── web.xml │ ├── about.xhtml │ ├── admin │ ├── users.xhtml │ └── users │ │ └── edit.xhtml │ ├── contact.xhtml │ ├── cookie-policy.xhtml │ ├── favicon.ico │ ├── help.xhtml │ ├── home.xhtml │ ├── login.xhtml │ ├── privacy-policy.xhtml │ ├── reset-password.xhtml │ ├── resources │ ├── composites │ │ ├── inputLocalDate.properties │ │ ├── inputLocalDate.xhtml │ │ └── inputLocalDate_nl.properties │ ├── flags │ │ ├── de.svg │ │ ├── en.svg │ │ ├── es.svg │ │ ├── fr.svg │ │ └── nl.svg │ ├── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon-96x96.png │ ├── images │ │ ├── icon.png │ │ └── logo.png │ ├── scripts │ │ ├── functions.js │ │ ├── listeners.js │ │ ├── onload.js │ │ └── primefaces.js │ └── styles │ │ ├── colors.css │ │ ├── layout.css │ │ └── primefaces.css │ ├── signup.xhtml │ ├── terms-of-service.xhtml │ └── user │ └── profile.xhtml └── test ├── java └── org │ └── example │ └── kickoff │ ├── arquillian │ └── ArquillianDBUnitTestBase.java │ ├── business │ └── UserServiceTest.java │ └── view │ └── LoginIT.java └── resources ├── arquillian.xml ├── dbunit └── UserServiceTest.xml ├── test-persistence.xml └── test-web.xml /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/java,maven,eclipse,intellij,netbeans,windows,osx,linux 3 | 4 | ### Java ### 5 | *.class 6 | 7 | # Mobile Tools for Java (J2ME) 8 | .mtj.tmp/ 9 | 10 | # Package Files # 11 | *.jar 12 | *.war 13 | *.ear 14 | 15 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 16 | hs_err_pid* 17 | 18 | 19 | ### Maven ### 20 | target/ 21 | pom.xml.tag 22 | pom.xml.releaseBackup 23 | pom.xml.versionsBackup 24 | pom.xml.next 25 | release.properties 26 | dependency-reduced-pom.xml 27 | buildNumber.properties 28 | .mvn/timing.properties 29 | 30 | 31 | ### Eclipse ### 32 | 33 | .metadata 34 | bin/ 35 | tmp/ 36 | *.tmp 37 | *.bak 38 | *.swp 39 | *~.nib 40 | local.properties 41 | .settings/ 42 | .loadpath 43 | 44 | # Eclipse Core 45 | .project 46 | 47 | # External tool builders 48 | .externalToolBuilders/ 49 | 50 | # Locally stored "Eclipse launch configurations" 51 | *.launch 52 | 53 | # PyDev specific (Python IDE for Eclipse) 54 | *.pydevproject 55 | 56 | # CDT-specific (C/C++ Development Tooling) 57 | .cproject 58 | 59 | # JDT-specific (Eclipse Java Development Tools) 60 | .classpath 61 | 62 | # Java annotation processor (APT) 63 | .factorypath 64 | 65 | # PDT-specific (PHP Development Tools) 66 | .buildpath 67 | 68 | # sbteclipse plugin 69 | .target 70 | 71 | # Tern plugin 72 | .tern-project 73 | 74 | # TeXlipse plugin 75 | .texlipse 76 | 77 | # STS (Spring Tool Suite) 78 | .springBeans 79 | 80 | # Code Recommenders 81 | .recommenders/ 82 | 83 | # JSF plugin 84 | *.jsfdia 85 | 86 | 87 | ### Intellij ### 88 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 89 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 90 | 91 | # User-specific stuff: 92 | .idea/workspace.xml 93 | .idea/tasks.xml 94 | .idea/dictionaries 95 | .idea/vcs.xml 96 | .idea/jsLibraryMappings.xml 97 | 98 | # Sensitive or high-churn files: 99 | .idea/dataSources.ids 100 | .idea/dataSources.xml 101 | .idea/sqlDataSources.xml 102 | .idea/dynamic.xml 103 | .idea/uiDesigner.xml 104 | 105 | # Gradle: 106 | .idea/gradle.xml 107 | .idea/libraries 108 | 109 | # Mongo Explorer plugin: 110 | .idea/mongoSettings.xml 111 | 112 | ## File-based project format: 113 | *.iws 114 | 115 | ## Plugin-specific files: 116 | 117 | # IntelliJ 118 | /out/ 119 | 120 | # mpeltonen/sbt-idea plugin 121 | .idea_modules/ 122 | 123 | # JIRA plugin 124 | atlassian-ide-plugin.xml 125 | 126 | # Crashlytics plugin (for Android Studio and IntelliJ) 127 | com_crashlytics_export_strings.xml 128 | crashlytics.properties 129 | crashlytics-build.properties 130 | fabric.properties 131 | 132 | # Manually added 133 | .idea/ 134 | *.iml 135 | 136 | ### NetBeans ### 137 | nbproject/private/ 138 | build/ 139 | nbbuild/ 140 | dist/ 141 | nbdist/ 142 | nbactions.xml 143 | .nb-gradle/ 144 | 145 | 146 | ### Windows ### 147 | # Windows image file caches 148 | Thumbs.db 149 | ehthumbs.db 150 | 151 | # Folder config file 152 | Desktop.ini 153 | 154 | # Recycle Bin used on file shares 155 | $RECYCLE.BIN/ 156 | 157 | # Windows Installer files 158 | *.cab 159 | *.msi 160 | *.msm 161 | *.msp 162 | 163 | # Windows shortcuts 164 | *.lnk 165 | 166 | 167 | ### OSX ### 168 | .DS_Store 169 | .AppleDouble 170 | .LSOverride 171 | 172 | # Icon must end with two \r 173 | Icon 174 | 175 | 176 | # Thumbnails 177 | ._* 178 | 179 | # Files that might appear in the root of a volume 180 | .DocumentRevisions-V100 181 | .fseventsd 182 | .Spotlight-V100 183 | .TemporaryItems 184 | .Trashes 185 | .VolumeIcon.icns 186 | 187 | # Directories potentially created on remote AFP share 188 | .AppleDB 189 | .AppleDesktop 190 | Network Trash Folder 191 | Temporary Items 192 | .apdisk 193 | 194 | 195 | ### Linux ### 196 | *~ 197 | 198 | # temporary files which can be created if a process still has a handle open of a deleted file 199 | .fuse_hidden* 200 | 201 | # KDE directory preferences 202 | .directory 203 | 204 | # Linux trash folder which might appear on any partition or disk 205 | .Trash-* 206 | 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Jakarta EE kickoff app 2 | =================== 3 | 4 | Basic project template to kickoff a new Jakarta EE or Java EE web application. 5 | 6 | ## Usage 7 | 8 | Clone the branch matching your target environment into a new project. There are currently three branches available. 9 | 10 | - [Jakarta EE 10 with Faces 4.0](https://github.com/javaeekickoff/java-ee-kickoff-app/tree/master) (master) 11 | - [Java EE 8 with JSF 2.3](https://github.com/javaeekickoff/java-ee-kickoff-app/tree/javaee8-jsf23) 12 | - [Java EE 7 with JSF 2.3](https://github.com/javaeekickoff/java-ee-kickoff-app/tree/javaee7-jsf23) 13 | - [Java EE 7 with JSF 2.2](https://github.com/javaeekickoff/java-ee-kickoff-app/tree/javaee7-jsf22) 14 | 15 | Note: For usage on JBoss EAP 7 alpha/beta or WildFly 10rc4 and below, JASPIC needs to be activated before the application can be deployed. See also http://arjan-tijms.omnifaces.org/2015/08/activating-jaspic-in-jboss-wildfly.html 16 | 17 | Fow WildFly 25 and higher Jakarta Authentication (new name for JASPIC) once again needs to be activated before the application can be deployed (it's a recurring theme for WilfFly it seems). See also https://stackoverflow.com/a/70240973/472792 18 | 19 | ## Database 20 | 21 | The default app uses [H2](http://www.h2database.com) with `drop-and-create`. In other words, the default app uses an embedded database and is configured to drop and create all tables on startup. If you want to stop dropping all tables on startup, edit `jakarta.persistence.schema-generation.database.action` property of `/META-INF/persistence.xml` to `create`. If you want to change the database, install the desired JDBC driver in your target server and edit the JDBC driver configuration in `/META-INF/conf/datasource-settings.xml`. It's not necessary to manually create a data source in the target server as that's already done via `` in `web.xml`. 22 | -------------------------------------------------------------------------------- /archetype.properties: -------------------------------------------------------------------------------- 1 | applicationName=Jakarta EE kickoff app 2 | projectURL=https://github.com/javaeekickoff/java-ee-kickoff-app 3 | scmURL=scm:git:git@github.com:javaeekickoff/java-ee-kickoff-app.git 4 | 5 | 6 | excludePatterns=.idea/**,*.iml,target/**,.project,.classpath,.settings/** -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Copyright 2018 OmniFaces 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with 4 | the License. You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on 9 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the 10 | specific language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/BackgroundJobs.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business; 2 | 3 | import jakarta.ejb.Schedule; 4 | import jakarta.ejb.Singleton; 5 | import jakarta.inject.Inject; 6 | import jakarta.persistence.EntityManager; 7 | import jakarta.persistence.PersistenceContext; 8 | 9 | import org.example.kickoff.business.service.LoginTokenService; 10 | 11 | @Singleton 12 | public class BackgroundJobs { 13 | 14 | @PersistenceContext 15 | private EntityManager entityManager; 16 | 17 | @Inject 18 | private LoginTokenService loginTokenService; 19 | 20 | @Schedule(dayOfWeek = "*", hour = "*", minute = "0", second = "0", persistent = false) 21 | public void hourly() { 22 | loginTokenService.removeExpired(); 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/EmailLoader.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import static jakarta.ejb.ConcurrencyManagementType.BEAN; 4 | import static org.omnifaces.utils.properties.PropertiesUtils.loadPropertiesFromClasspath; 5 | 6 | import java.util.Map; 7 | 8 | import jakarta.annotation.PostConstruct; 9 | import jakarta.ejb.ConcurrencyManagement; 10 | import jakarta.ejb.Singleton; 11 | import jakarta.ejb.Startup; 12 | import jakarta.enterprise.inject.Produces; 13 | import jakarta.inject.Named; 14 | 15 | @Startup 16 | @Singleton 17 | @ConcurrencyManagement(BEAN) 18 | public class EmailLoader { 19 | 20 | private Map emails; 21 | 22 | @PostConstruct 23 | public void init() { 24 | emails = loadPropertiesFromClasspath("META-INF/conf/emails"); 25 | } 26 | 27 | @Produces 28 | @Named("emails") 29 | @Emails 30 | public Map getEmails() { 31 | return emails; 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/EmailService.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import static org.example.kickoff.business.email.EmailTemplate.EmailTemplatePart.BODY_TITLE; 4 | import static org.example.kickoff.business.email.EmailTemplate.EmailTemplatePart.SUBJECT_CONTENT; 5 | import static org.omnifaces.utils.Lang.isEmpty; 6 | 7 | import java.util.Arrays; 8 | import java.util.HashMap; 9 | import java.util.List; 10 | import java.util.Map; 11 | 12 | import org.example.kickoff.business.email.EmailTemplate.EmailTemplatePart; 13 | import org.example.kickoff.business.exception.SystemException; 14 | import org.omnifaces.cdi.settings.ApplicationSetting; 15 | 16 | import jakarta.annotation.PostConstruct; 17 | import jakarta.inject.Inject; 18 | 19 | public abstract class EmailService { 20 | 21 | private static final String LOG_MAIL_FAIL = "Failed to send mail [type=%s, to=%s, replyTo=%s, subject=%s]"; 22 | private static final List ALLOWED_FROM_DOMAINS = Arrays.asList("kickoff.example.org"); 23 | 24 | private EmailUser defaultEmailUser; 25 | 26 | @Inject @ApplicationSetting 27 | private String fromEmail; 28 | 29 | @Inject @ApplicationSetting 30 | private Boolean disableEmailService; 31 | 32 | @Inject 33 | private EmailTemplateService emailTemplateService; 34 | 35 | @PostConstruct 36 | public void init() { 37 | defaultEmailUser = new EmailUser(fromEmail, "The Jakarta EE Kickoff Team"); 38 | } 39 | 40 | public void sendTemplate(EmailTemplate templateEmail) { 41 | sendTemplate(templateEmail, new HashMap<>()); 42 | } 43 | 44 | public void sendTemplate(EmailTemplate templateEmail, Map messageParameters) { 45 | if (disableEmailService) { 46 | return; 47 | } 48 | 49 | if (templateEmail.getFromUser() == null) { 50 | templateEmail.setFromUser(defaultEmailUser); 51 | } 52 | else { // Prevent sending email from other domains. 53 | EmailUser fromUser = templateEmail.getFromUser(); 54 | String email = fromUser.getEmail(); 55 | 56 | if (email != null && email.contains("@") && !ALLOWED_FROM_DOMAINS.contains(email.substring(email.indexOf("@") + 1))) { 57 | templateEmail.setFromUser(new EmailUser(defaultEmailUser.getEmail(), fromUser.getFullName() + " via Jakarta EE Kickoff App")); 58 | templateEmail.setReplyTo(fromUser.getEmail()); 59 | } 60 | } 61 | 62 | if (templateEmail.getToUser() == null) { 63 | templateEmail.setToUser(defaultEmailUser); 64 | } 65 | 66 | emailTemplateService.addUserParameters("toUser", templateEmail.getToUser(), messageParameters); 67 | emailTemplateService.addUserParameters("fromUser", templateEmail.getFromUser(), messageParameters); 68 | 69 | try { 70 | Map templateContent = buildTemplateContent(templateEmail, messageParameters); 71 | sendTemplateMessage(templateEmail, messageParameters, templateContent); 72 | } 73 | catch (Exception e) { 74 | String errorMessage = String.format(LOG_MAIL_FAIL, templateEmail.getType(), templateEmail.getToUser().getEmail(), templateEmail.getReplyTo(), templateEmail.getTemplateParts().get(SUBJECT_CONTENT)); 75 | throw new SystemException(errorMessage, e); 76 | } 77 | } 78 | 79 | public abstract void sendTemplateMessage(EmailTemplate templateEmail, Map messageParameters, Map templateContent); 80 | 81 | public void sendPlainText(EmailUser to, String subject, String body) { 82 | sendPlainText(to, defaultEmailUser, subject, body, null); 83 | } 84 | 85 | public void sendPlainText(EmailUser to, EmailUser from, String subject, String body, String replyTo) { 86 | if (disableEmailService) { 87 | return; 88 | } 89 | 90 | try { 91 | sendPlainTextMessage(to, from, subject, body, replyTo); 92 | } 93 | catch (Exception e) { 94 | String errorMessage = String.format(LOG_MAIL_FAIL, to, replyTo, subject, body); 95 | throw new SystemException(errorMessage, e); 96 | } 97 | } 98 | 99 | public abstract void sendPlainTextMessage(EmailUser to, EmailUser from, String subject, String body, String replyTo); 100 | 101 | private Map buildTemplateContent(EmailTemplate templateEmail, Map messageParameters) { 102 | Map templateParts = templateEmail.getTemplateParts(); 103 | Arrays.stream(EmailTemplatePart.values()) 104 | .filter(part -> !templateParts.containsKey(part.getKey())) 105 | .forEach(part -> templateParts.putIfAbsent(part, emailTemplateService.build(templateEmail, part, messageParameters))); 106 | 107 | if (isEmpty(templateParts.get(BODY_TITLE))) { 108 | String subjectContent = templateParts.get(SUBJECT_CONTENT); 109 | templateParts.put(BODY_TITLE, subjectContent); 110 | } 111 | 112 | Map templateContent = new HashMap<>(); 113 | templateEmail.getTemplateParts().keySet().stream() 114 | .forEach(key -> templateContent.put(key.getKey(), templateEmail.getTemplateParts().get(key))); 115 | 116 | return templateContent; 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/EmailTemplate.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import java.util.HashMap; 4 | import java.util.Map; 5 | 6 | public class EmailTemplate { 7 | 8 | public enum EmailTemplatePart { 9 | SUBJECT_CONTENT("subject_content", false), 10 | SALUTATION_CONTENT("salutation_content", false), 11 | BODY_TITLE("body_title", false), 12 | BODY_CONTENT("body_content", true), 13 | OBJECT_CONTENT("object_content", true), 14 | CALL_TO_ACTION_LABEL("call_to_action_label", false); 15 | 16 | private final String key; 17 | private final boolean html; 18 | 19 | EmailTemplatePart(String key, boolean html) { 20 | this.key = key; 21 | this.html = html; 22 | } 23 | 24 | public String getKey() { 25 | return key; 26 | } 27 | 28 | public boolean isHtml() { 29 | return html; 30 | } 31 | } 32 | 33 | private String templateId; 34 | 35 | // TODO convert to enum 36 | private String type; 37 | 38 | private EmailUser toUser; 39 | private EmailUser fromUser; 40 | private String replyTo; 41 | private boolean sendBccToFromUser = false; 42 | 43 | private Map templateParts = new HashMap<>(); 44 | 45 | private String callToActionURL; 46 | 47 | public EmailTemplate(String type) { 48 | this.type = type; 49 | } 50 | 51 | public EmailTemplate(String templateId, String type) { 52 | this.templateId = templateId; 53 | this.type = type; 54 | } 55 | 56 | public String getTemplateId() { 57 | return templateId; 58 | } 59 | 60 | public String getType() { 61 | return type; 62 | } 63 | 64 | public EmailUser getToUser() { 65 | return toUser; 66 | } 67 | 68 | public EmailTemplate setToUser(EmailUser toUser) { 69 | this.toUser = toUser; 70 | return this; 71 | } 72 | 73 | public EmailUser getFromUser() { 74 | return fromUser; 75 | } 76 | 77 | public EmailTemplate setFromUser(EmailUser fromUser) { 78 | this.fromUser = fromUser; 79 | return this; 80 | } 81 | 82 | public String getReplyTo() { 83 | return replyTo; 84 | } 85 | 86 | public EmailTemplate setReplyTo(String replyTo) { 87 | this.replyTo = replyTo; 88 | return this; 89 | } 90 | 91 | public boolean isSendBccToFromUser() { 92 | return sendBccToFromUser; 93 | } 94 | 95 | public EmailTemplate setSendBccToFromUser(boolean sendBccToFromUser) { 96 | this.sendBccToFromUser = sendBccToFromUser; 97 | return this; 98 | } 99 | 100 | public Map getTemplateParts() { 101 | return templateParts; 102 | } 103 | 104 | public EmailTemplate setTemplateParts(Map templateParts) { 105 | this.templateParts = templateParts; 106 | return this; 107 | } 108 | 109 | public EmailTemplate setTemplatePart(EmailTemplatePart key, String value) { 110 | templateParts.put(key, value); 111 | return this; 112 | } 113 | 114 | public String getCallToActionURL() { 115 | return callToActionURL; 116 | } 117 | 118 | public EmailTemplate setCallToActionURL(String url) { 119 | callToActionURL = url; 120 | return this; 121 | } 122 | 123 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/EmailTemplateService.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import java.util.Map; 4 | 5 | import jakarta.ejb.Stateless; 6 | import jakarta.inject.Inject; 7 | 8 | import org.example.kickoff.business.email.EmailTemplate.EmailTemplatePart; 9 | import org.omnifaces.utils.text.NameBasedMessageFormat; 10 | 11 | @Stateless 12 | public class EmailTemplateService { 13 | 14 | @Inject 15 | @Emails 16 | private Map emails; 17 | 18 | public String build(EmailTemplate templateEmail, EmailTemplatePart templatePart, Map messageParameters) { 19 | String pattern = templatePart.isHtml() ? emails.get(templateEmail.getType() + "_" + templatePart.getKey() + "_HTML") 20 | : emails.get(templateEmail.getType() + "_" + templatePart.getKey() + "_text"); 21 | 22 | // Defaults 23 | if (pattern == null) { 24 | pattern = templatePart.isHtml() ? emails.get("default_" + templatePart.getKey() + "_HTML") 25 | : emails.get("default_" + templatePart.getKey() + "_text"); 26 | } 27 | 28 | if (pattern != null) { 29 | String content = NameBasedMessageFormat.format(pattern, messageParameters).trim(); 30 | 31 | if (templatePart.isHtml()) { 32 | content = content.replaceAll("(\r\n|\n){2,}", "

"); 33 | } 34 | 35 | return content; 36 | } 37 | 38 | return null; 39 | } 40 | 41 | public Map addUserParameters(String prefix, EmailUser user, Map messageParameters) { 42 | messageParameters.putIfAbsent(prefix + ".id", user.getId()); 43 | messageParameters.putIfAbsent(prefix + ".email", user.getEmail()); 44 | messageParameters.putIfAbsent(prefix + ".fullName", user.getFullName()); 45 | 46 | String linkPattern = String.format("%s", String.format("{%s.fullName}", prefix)); 47 | messageParameters.putIfAbsent(prefix, NameBasedMessageFormat.format(linkPattern, messageParameters)); 48 | 49 | return messageParameters; 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/EmailUser.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import jakarta.mail.internet.InternetAddress; 4 | 5 | import org.example.kickoff.model.Person; 6 | 7 | public class EmailUser { 8 | 9 | private final Long id; 10 | private final String email; 11 | private final String fullName; 12 | 13 | public EmailUser(Person person) { 14 | this(person.getId(), person.getEmail(), person.getFullName()); 15 | } 16 | 17 | public EmailUser(String email, String fullName) { 18 | this(null, email, fullName); 19 | } 20 | 21 | private EmailUser(Long id, String email, String fullName) { 22 | try { 23 | new InternetAddress(email).validate(); 24 | } 25 | catch (Exception e) { 26 | throw new IllegalArgumentException("invalid email"); 27 | } 28 | 29 | this.id = id; 30 | this.email = email; 31 | this.fullName = fullName; 32 | } 33 | 34 | public Long getId() { 35 | return id; 36 | } 37 | 38 | public String getEmail() { 39 | return email; 40 | } 41 | 42 | public String getFullName() { 43 | return fullName; 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/Emails.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import static java.lang.annotation.ElementType.FIELD; 4 | import static java.lang.annotation.ElementType.METHOD; 5 | import static java.lang.annotation.ElementType.PARAMETER; 6 | import static java.lang.annotation.ElementType.TYPE; 7 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 8 | 9 | import java.lang.annotation.Retention; 10 | import java.lang.annotation.Target; 11 | 12 | import jakarta.inject.Qualifier; 13 | 14 | @Qualifier 15 | @Retention(RUNTIME) 16 | @Target({TYPE, METHOD, FIELD, PARAMETER}) 17 | public @interface Emails { 18 | // 19 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/email/KickoffEmailService.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.email; 2 | 3 | import java.util.Map; 4 | 5 | import jakarta.ejb.Stateless; 6 | 7 | @Stateless 8 | public class KickoffEmailService extends EmailService { 9 | 10 | @Override 11 | public void sendTemplateMessage(EmailTemplate templateEmail, Map messageParameters, Map templateContent) { 12 | // TODO: implement. 13 | System.out.println("KickoffEmailService.sendTemplateMessage()"); 14 | System.out.println("Call to action URL: " + templateEmail.getCallToActionURL()); 15 | System.out.println("Message parameters: " + messageParameters); 16 | System.out.println("Template content: " + templateContent); 17 | } 18 | 19 | @Override 20 | public void sendPlainTextMessage(EmailUser to, EmailUser from, String subject, String body, String replyTo) { 21 | // TODO: implement. 22 | System.out.println("KickoffEmailService.sendPlainTextMessage()"); 23 | System.out.println("To: " + to); 24 | System.out.println("From: " + from); 25 | System.out.println("Subject: " + subject); 26 | System.out.println("Body: " + body); 27 | System.out.println("Reply to: " + replyTo); 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/BusinessException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | import jakarta.ejb.ApplicationException; 4 | 5 | @ApplicationException(rollback = true) 6 | public abstract class BusinessException extends RuntimeException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | public BusinessException() { 11 | super(); 12 | } 13 | 14 | public BusinessException(String message) { 15 | super(message); 16 | } 17 | 18 | public BusinessException(Throwable cause) { 19 | super(cause); 20 | } 21 | 22 | public BusinessException(String message, Throwable cause) { 23 | super(message, cause); 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/CredentialsException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public abstract class CredentialsException extends BusinessException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/DuplicateEntityException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when an unique constraint is violated. 5 | */ 6 | public class DuplicateEntityException extends EntityException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/EmailNotVerifiedException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public class EmailNotVerifiedException extends CredentialsException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/EntityAlreadyModifiedException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public class EntityAlreadyModifiedException extends EntityException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/EntityException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public abstract class EntityException extends BusinessException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/EntityNotFoundException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when entity cannot be found by ID. 5 | */ 6 | public class EntityNotFoundException extends EntityException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/InvalidGroupException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when login username does exist in DB, but user does not have the correct group. 5 | */ 6 | public class InvalidGroupException extends CredentialsException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/InvalidPasswordException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when login username does exist in DB, but password does not match. 5 | */ 6 | public class InvalidPasswordException extends CredentialsException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/InvalidTokenException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when login token does not exist in DB or is expired. 5 | */ 6 | public class InvalidTokenException extends CredentialsException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/InvalidUsernameException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | /** 4 | * Thrown when login username does not exist in DB. 5 | */ 6 | public class InvalidUsernameException extends CredentialsException { 7 | 8 | private static final long serialVersionUID = 1L; 9 | 10 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/NonDeletableEntityException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public class NonDeletableEntityException extends EntityException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/exception/SystemException.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.exception; 2 | 3 | public class SystemException extends BusinessException { 4 | 5 | private static final long serialVersionUID = 1L; 6 | 7 | public SystemException() { 8 | super(); 9 | } 10 | 11 | public SystemException(String message) { 12 | super(message); 13 | } 14 | 15 | public SystemException(Throwable cause) { 16 | super(cause); 17 | } 18 | 19 | public SystemException(String message, Throwable cause) { 20 | super(message, cause); 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/service/LoginTokenService.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.service; 2 | 3 | import static java.time.Instant.now; 4 | import static java.time.temporal.ChronoUnit.DAYS; 5 | import static java.util.UUID.randomUUID; 6 | import static org.omnifaces.utils.security.MessageDigests.digest; 7 | 8 | import java.time.Instant; 9 | 10 | import jakarta.ejb.Stateless; 11 | import jakarta.inject.Inject; 12 | 13 | import org.example.kickoff.business.exception.InvalidUsernameException; 14 | import org.example.kickoff.model.LoginToken; 15 | import org.example.kickoff.model.LoginToken.TokenType; 16 | import org.example.kickoff.model.Person; 17 | import org.omnifaces.persistence.service.BaseEntityService; 18 | 19 | @Stateless 20 | public class LoginTokenService extends BaseEntityService { 21 | 22 | private static final String MESSAGE_DIGEST_ALGORITHM = "SHA-256"; 23 | 24 | @Inject 25 | private PersonService personService; 26 | 27 | public String generate(String email, String ipAddress, String description, TokenType tokenType) { 28 | Instant expiration = now().plus(14, DAYS); 29 | return generate(email, ipAddress, description, tokenType, expiration); 30 | } 31 | 32 | public String generate(String email, String ipAddress, String description, TokenType tokenType, Instant expiration) { 33 | String rawToken = randomUUID().toString(); 34 | Person person = personService.findByEmail(email).orElseThrow(InvalidUsernameException::new); 35 | 36 | LoginToken loginToken = new LoginToken(); 37 | loginToken.setTokenHash(digest(rawToken, MESSAGE_DIGEST_ALGORITHM)); 38 | loginToken.setExpiration(expiration); 39 | loginToken.setDescription(description); 40 | loginToken.setType(tokenType); 41 | loginToken.setIpAddress(ipAddress); 42 | loginToken.setPerson(person); 43 | person.getLoginTokens().add(loginToken); 44 | return rawToken; 45 | } 46 | 47 | public void remove(String loginToken) { 48 | createNamedQuery("LoginToken.remove") 49 | .setParameter("tokenHash", digest(loginToken, MESSAGE_DIGEST_ALGORITHM)) 50 | .executeUpdate(); 51 | } 52 | 53 | public void removeExpired() { 54 | createNamedQuery("LoginToken.removeExpired") 55 | .executeUpdate(); 56 | } 57 | 58 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/business/service/PersonService.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.business.service; 2 | 3 | import static java.util.Arrays.asList; 4 | import static org.example.kickoff.model.Group.USER; 5 | import static org.omnifaces.persistence.JPA.getOptionalSingleResult; 6 | import static org.omnifaces.utils.security.MessageDigests.digest; 7 | 8 | import java.time.ZonedDateTime; 9 | import java.util.HashMap; 10 | import java.util.Map; 11 | import java.util.Optional; 12 | import java.util.concurrent.TimeUnit; 13 | 14 | import jakarta.annotation.Resource; 15 | import jakarta.ejb.SessionContext; 16 | import jakarta.ejb.Stateless; 17 | import jakarta.inject.Inject; 18 | 19 | import org.example.kickoff.business.email.EmailService; 20 | import org.example.kickoff.business.email.EmailTemplate; 21 | import org.example.kickoff.business.email.EmailUser; 22 | import org.example.kickoff.business.exception.DuplicateEntityException; 23 | import org.example.kickoff.business.exception.InvalidPasswordException; 24 | import org.example.kickoff.business.exception.InvalidTokenException; 25 | import org.example.kickoff.business.exception.InvalidUsernameException; 26 | import org.example.kickoff.model.Credentials; 27 | import org.example.kickoff.model.Group; 28 | import org.example.kickoff.model.LoginToken.TokenType; 29 | import org.example.kickoff.model.Person; 30 | import org.omnifaces.persistence.service.BaseEntityService; 31 | 32 | @Stateless 33 | public class PersonService extends BaseEntityService { 34 | 35 | private static final long DEFAULT_PASSWORD_RESET_EXPIRATION_TIME_IN_MINUTES = TimeUnit.HOURS.toMinutes(1); 36 | 37 | @Resource 38 | private SessionContext sessionContext; 39 | 40 | @Inject 41 | private LoginTokenService loginTokenService; 42 | 43 | @Inject 44 | private EmailService emailService; 45 | 46 | public void register(Person person, String password, Group... additionalGroups) { 47 | if (findByEmail(person.getEmail()).isPresent()) { 48 | throw new DuplicateEntityException(); 49 | } 50 | 51 | person.getGroups().add(USER); 52 | person.getGroups().addAll(asList(additionalGroups)); 53 | persist(person); 54 | setPassword(person, password); 55 | } 56 | 57 | @Override 58 | public Person update(Person person) { 59 | Person existingPerson = manage(person); 60 | 61 | if (!person.getEmail().equals(existingPerson.getEmail())) { // Email changed. 62 | Optional otherPerson = findByEmail(person.getEmail()); 63 | 64 | if (otherPerson.isPresent()) { 65 | if (!person.equals(otherPerson.get())) { 66 | throw new DuplicateEntityException(); 67 | } 68 | else { 69 | // Since email verification status can be updated asynchronous, the DB status is leading. 70 | // Set the current person to whatever is already in the DB. 71 | person.setEmailVerified(otherPerson.get().isEmailVerified()); 72 | } 73 | } 74 | else { 75 | person.setEmailVerified(false); 76 | } 77 | } 78 | 79 | return super.update(person); 80 | } 81 | 82 | public void updatePassword(Person person, String password) { 83 | Person existingPerson = manage(person); 84 | setPassword(existingPerson, password); 85 | super.update(existingPerson); 86 | } 87 | 88 | public void updatePassword(String loginToken, String password) { 89 | Optional person = findByLoginToken(loginToken, TokenType.RESET_PASSWORD); 90 | 91 | if (person.isPresent()) { 92 | updatePassword(person.get(), password); 93 | loginTokenService.remove(loginToken); 94 | } 95 | } 96 | 97 | public void requestResetPassword(String email, String ipAddress, String callbackUrlFormat) { 98 | Person person = findByEmail(email).orElseThrow(InvalidUsernameException::new); 99 | ZonedDateTime expiration = ZonedDateTime.now().plusMinutes(DEFAULT_PASSWORD_RESET_EXPIRATION_TIME_IN_MINUTES); 100 | String token = loginTokenService.generate(email, ipAddress, "Reset Password", TokenType.RESET_PASSWORD, expiration.toInstant()); 101 | 102 | EmailTemplate emailTemplate = new EmailTemplate("resetPassword") 103 | .setToUser(new EmailUser(person)) 104 | .setCallToActionURL(String.format(callbackUrlFormat, token)); 105 | 106 | Map messageParameters = new HashMap<>(); 107 | messageParameters.put("expiration", expiration); 108 | messageParameters.put("ip", ipAddress); 109 | 110 | emailService.sendTemplate(emailTemplate, messageParameters); 111 | } 112 | 113 | public Optional findByEmail(String email) { 114 | return getOptionalSingleResult(createNamedTypedQuery("Person.getByEmail") 115 | .setParameter("email", email)); 116 | } 117 | 118 | public Optional findByLoginToken(String loginToken, TokenType type) { 119 | return getOptionalSingleResult(createNamedTypedQuery("Person.getByLoginToken") 120 | .setParameter("tokenHash", digest(loginToken, "SHA-256")) 121 | .setParameter("tokenType", type)); 122 | } 123 | 124 | public Person getByEmail(String email) { 125 | return findByEmail(email).orElseThrow(InvalidUsernameException::new); 126 | } 127 | 128 | public Person getByEmailAndPassword(String email, String password) { 129 | Person person = getByEmail(email); 130 | 131 | if (!person.getCredentials().isValid(password)) { 132 | throw new InvalidPasswordException(); 133 | } 134 | 135 | return person; 136 | } 137 | 138 | public Person getByLoginToken(String loginToken, TokenType type) { 139 | return findByLoginToken(loginToken, type).orElseThrow(InvalidTokenException::new); 140 | } 141 | 142 | public Person getActivePerson() { 143 | return findByEmail(sessionContext.getCallerPrincipal().getName()).orElse(null); 144 | } 145 | 146 | public void setPassword(Person person, String password) { 147 | Person managedPerson = manage(person); 148 | Credentials credentials = managedPerson.getCredentials(); 149 | 150 | if (credentials == null) { 151 | credentials = new Credentials(); 152 | credentials.setPerson(managedPerson); 153 | } 154 | 155 | credentials.setPassword(password); 156 | } 157 | 158 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/DataSourceStagedPropertiesFileLoader.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config; 2 | 3 | import static java.util.logging.Logger.getLogger; 4 | import static org.omnifaces.utils.properties.PropertiesUtils.loadPropertiesFromClasspath; 5 | import static org.omnifaces.utils.properties.PropertiesUtils.loadXMLPropertiesStagedFromClassPath; 6 | 7 | import java.util.Map; 8 | import java.util.logging.Logger; 9 | 10 | import org.omnifaces.persistence.datasource.PropertiesFileLoader; 11 | 12 | /** 13 | * Adaptor for the switchable datasource as defined in web.xml to be able to read properties from the 14 | * right file. 15 | */ 16 | public class DataSourceStagedPropertiesFileLoader implements PropertiesFileLoader { 17 | 18 | private static final Logger logger = getLogger(DataSourceStagedPropertiesFileLoader.class.getName()); 19 | 20 | @Override 21 | public Map loadFromFile(String fileName) { 22 | 23 | // Make sure we use the same names as the application settings are using 24 | Map omniSettings = loadPropertiesFromClasspath("META-INF/omni-settings"); 25 | 26 | Map dataSourceProperties = loadXMLPropertiesStagedFromClassPath( 27 | fileName, 28 | omniSettings.getOrDefault("stageSystemPropertyName", "omni.stage"), 29 | omniSettings.get("defaultStage")); 30 | 31 | logger.info( 32 | "\n\nAbout to install DataSource. \n" + 33 | "Classname: " + dataSourceProperties.get("className") + "\n" + 34 | "URL: " + dataSourceProperties.getOrDefault("url", dataSourceProperties.get("URL") + "\n" + 35 | "See META-INF/conf/" + fileName + " for details. \n" + 36 | "\n\n") 37 | ); 38 | 39 | return dataSourceProperties; 40 | 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/InstantConverter.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config; 2 | import java.time.Instant; 3 | import java.util.Date; 4 | 5 | import jakarta.persistence.AttributeConverter; 6 | import jakarta.persistence.Converter; 7 | 8 | /** 9 | * Converts the JDK 8 / JSR 310 Instant type to Date for 10 | * usage with JPA. 11 | * 12 | *

13 | * This converter is still necessary for Jakarta EE 10 / Jakarta Persistence 3.1, since Instant remains to be one of the 14 | * "forgotten" types. 15 | * 16 | * See https://github.com/jakartaee/persistence/issues/163 17 | * 18 | * @author Arjan Tijms 19 | */ 20 | @Converter(autoApply = true) 21 | public class InstantConverter implements AttributeConverter { 22 | 23 | @Override 24 | public Date convertToDatabaseColumn(Instant instant) { 25 | if (instant == null) { 26 | return null; 27 | } 28 | 29 | return Date.from(instant); 30 | } 31 | 32 | @Override 33 | public Instant convertToEntityAttribute(Date date) { 34 | if (date == null) { 35 | return null; 36 | } 37 | 38 | return date.toInstant(); 39 | } 40 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/StartupBean.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config; 2 | 3 | import static java.text.MessageFormat.format; 4 | import static java.util.ResourceBundle.getBundle; 5 | import static org.example.kickoff.model.Group.ADMIN; 6 | import static org.omnifaces.util.Faces.getLocale; 7 | import static org.omnifaces.utils.Lang.isEmpty; 8 | 9 | import java.util.ResourceBundle; 10 | 11 | import jakarta.annotation.PostConstruct; 12 | import jakarta.inject.Inject; 13 | 14 | import org.example.kickoff.business.service.PersonService; 15 | import org.example.kickoff.model.Person; 16 | import org.omnifaces.cdi.Startup; 17 | import org.omnifaces.util.Messages; 18 | 19 | @Startup 20 | public class StartupBean { 21 | 22 | @Inject 23 | private PersonService personService; 24 | 25 | @PostConstruct 26 | public void init() { 27 | setupTestPersons(); 28 | configureMessageResolver(); 29 | } 30 | 31 | private void setupTestPersons() { 32 | if (!personService.findByEmail("admin@kickoff.example.org").isPresent()) { 33 | Person person = new Person(); 34 | person.setFirstName("Test"); 35 | person.setLastName("Admin"); 36 | person.setEmail("admin@kickoff.example.org"); 37 | personService.register(person, "passw0rd", ADMIN); 38 | } 39 | 40 | if (!personService.findByEmail("user@kickoff.example.org").isPresent()) { 41 | Person person = new Person(); 42 | person.setFirstName("Test"); 43 | person.setLastName("User"); 44 | person.setEmail("person@kickoff.example.org"); 45 | personService.register(person, "passw0rd"); 46 | } 47 | } 48 | 49 | private void configureMessageResolver() { 50 | Messages.setResolver(new Messages.Resolver() { 51 | private static final String BASE_NAME = "org.example.kickoff.i18n.ApplicationBundle"; 52 | 53 | @Override 54 | public String getMessage(String message, Object... params) { 55 | ResourceBundle bundle = getBundle(BASE_NAME, getLocale()); 56 | if (bundle.containsKey(message)) { 57 | message = bundle.getString(message); 58 | } 59 | return isEmpty(params) ? message : format(message, params); 60 | } 61 | }); 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/auth/KickoffCallerPrincipal.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config.auth; 2 | 3 | import jakarta.security.enterprise.CallerPrincipal; 4 | 5 | import org.example.kickoff.model.Person; 6 | import org.example.kickoff.view.ActiveUser; 7 | 8 | /** 9 | * @see KickoffIdentityStore 10 | * @see ActiveUser 11 | */ 12 | public class KickoffCallerPrincipal extends CallerPrincipal { 13 | 14 | private static final long serialVersionUID = 1L; 15 | private final Person person; 16 | 17 | public KickoffCallerPrincipal(Person person) { 18 | super(person.getEmail()); 19 | this.person = person; 20 | } 21 | 22 | public Person getPerson() { 23 | return person; 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/auth/KickoffFormAuthenticationMechanism.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config.auth; 2 | import jakarta.enterprise.context.ApplicationScoped; 3 | import jakarta.inject.Inject; 4 | import jakarta.security.enterprise.AuthenticationStatus; 5 | import jakarta.security.enterprise.authentication.mechanism.http.AutoApplySession; 6 | import jakarta.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism; 7 | import jakarta.security.enterprise.authentication.mechanism.http.HttpMessageContext; 8 | import jakarta.security.enterprise.authentication.mechanism.http.LoginToContinue; 9 | import jakarta.security.enterprise.authentication.mechanism.http.RememberMe; 10 | import jakarta.security.enterprise.credential.Credential; 11 | import jakarta.security.enterprise.identitystore.IdentityStoreHandler; 12 | import jakarta.servlet.http.HttpServletRequest; 13 | import jakarta.servlet.http.HttpServletResponse; 14 | 15 | 16 | /** 17 | * Authentication mechanism that authenticates according to the Servlet spec defined FORM authentication mechanism. 18 | * See Servlet spec for further details. 19 | * 20 | * @author Arjan Tijms 21 | */ 22 | @AutoApplySession // For "Is user already logged-in?" 23 | @RememberMe( 24 | cookieSecureOnly = false, // Remove this when login is served over HTTPS. 25 | cookieMaxAgeSeconds = 60 * 60 * 24 * 14) // 14 days. 26 | @LoginToContinue( 27 | loginPage = "/login?continue=true", 28 | errorPage = "", 29 | useForwardToLogin = false) 30 | @ApplicationScoped 31 | public class KickoffFormAuthenticationMechanism implements HttpAuthenticationMechanism { 32 | 33 | @Inject 34 | private IdentityStoreHandler identityStoreHandler; 35 | 36 | @Override 37 | public AuthenticationStatus validateRequest(HttpServletRequest request, HttpServletResponse response, HttpMessageContext context) { 38 | Credential credential = context.getAuthParameters().getCredential(); 39 | 40 | if (credential != null) { 41 | return context.notifyContainerAboutLogin(identityStoreHandler.validate(credential)); 42 | } 43 | 44 | return context.doNothing(); 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/auth/KickoffIdentityStore.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config.auth; 2 | import static jakarta.security.enterprise.identitystore.CredentialValidationResult.INVALID_RESULT; 3 | import static jakarta.security.enterprise.identitystore.CredentialValidationResult.NOT_VALIDATED_RESULT; 4 | import static org.example.kickoff.model.Group.USER; 5 | 6 | import java.util.function.Supplier; 7 | 8 | import jakarta.enterprise.context.ApplicationScoped; 9 | import jakarta.inject.Inject; 10 | import jakarta.security.enterprise.credential.CallerOnlyCredential; 11 | import jakarta.security.enterprise.credential.Credential; 12 | import jakarta.security.enterprise.credential.UsernamePasswordCredential; 13 | import jakarta.security.enterprise.identitystore.CredentialValidationResult; 14 | import jakarta.security.enterprise.identitystore.IdentityStore; 15 | 16 | import org.example.kickoff.business.exception.CredentialsException; 17 | import org.example.kickoff.business.exception.EmailNotVerifiedException; 18 | import org.example.kickoff.business.exception.InvalidGroupException; 19 | import org.example.kickoff.business.service.PersonService; 20 | import org.example.kickoff.model.Person; 21 | 22 | @ApplicationScoped 23 | public class KickoffIdentityStore implements IdentityStore { 24 | 25 | @Inject 26 | private PersonService personService; 27 | 28 | @Override 29 | public CredentialValidationResult validate(Credential credential) { 30 | Supplier personSupplier = null; 31 | 32 | if (credential instanceof UsernamePasswordCredential) { 33 | String email = ((UsernamePasswordCredential) credential).getCaller(); 34 | String password = ((UsernamePasswordCredential) credential).getPasswordAsString(); 35 | personSupplier = () -> personService.getByEmailAndPassword(email, password); 36 | } 37 | else if (credential instanceof CallerOnlyCredential) { 38 | String email = ((CallerOnlyCredential) credential).getCaller(); 39 | personSupplier = () -> personService.getByEmail(email); 40 | } 41 | 42 | return validate(personSupplier); 43 | } 44 | 45 | static CredentialValidationResult validate(Supplier personSupplier) { 46 | if (personSupplier == null) { 47 | return NOT_VALIDATED_RESULT; 48 | } 49 | 50 | try { 51 | Person person = personSupplier.get(); 52 | 53 | if (!person.getGroups().contains(USER)) { 54 | throw new InvalidGroupException(); 55 | } 56 | 57 | if (!person.isEmailVerified()) { 58 | throw new EmailNotVerifiedException(); 59 | } 60 | 61 | return new CredentialValidationResult(new KickoffCallerPrincipal(person), person.getRolesAsStrings()); 62 | } 63 | catch (CredentialsException e) { 64 | return INVALID_RESULT; 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/config/auth/KickoffRememberMeIdentityStore.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.config.auth; 2 | 3 | import static org.example.kickoff.model.LoginToken.TokenType.REMEMBER_ME; 4 | import static org.omnifaces.util.Servlets.getRemoteAddr; 5 | 6 | import java.util.Set; 7 | 8 | import jakarta.enterprise.context.ApplicationScoped; 9 | import jakarta.inject.Inject; 10 | import jakarta.security.enterprise.CallerPrincipal; 11 | import jakarta.security.enterprise.credential.RememberMeCredential; 12 | import jakarta.security.enterprise.identitystore.CredentialValidationResult; 13 | import jakarta.security.enterprise.identitystore.RememberMeIdentityStore; 14 | import jakarta.servlet.http.HttpServletRequest; 15 | 16 | import org.example.kickoff.business.service.LoginTokenService; 17 | import org.example.kickoff.business.service.PersonService; 18 | 19 | @ApplicationScoped 20 | public class KickoffRememberMeIdentityStore implements RememberMeIdentityStore { 21 | 22 | @Inject 23 | private HttpServletRequest request; 24 | 25 | @Inject 26 | private PersonService personService; 27 | 28 | @Inject 29 | private LoginTokenService loginTokenService; 30 | 31 | @Override 32 | public CredentialValidationResult validate(RememberMeCredential credential) { 33 | return KickoffIdentityStore.validate(() -> personService.getByLoginToken(credential.getToken(), REMEMBER_ME)); 34 | } 35 | 36 | @Override 37 | public String generateLoginToken(CallerPrincipal callerPrincipal, Set groups) { 38 | String ipAddress = getRemoteAddr(request); 39 | String description = "Remember me session for " + ipAddress + " on " + request.getHeader("User-Agent"); 40 | return loginTokenService.generate(callerPrincipal.getName(), ipAddress, description, REMEMBER_ME); 41 | } 42 | 43 | @Override 44 | public void removeLoginToken(String loginToken) { 45 | loginTokenService.remove(loginToken); 46 | } 47 | 48 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/Credentials.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model; 2 | 3 | import static org.omnifaces.utils.security.MessageDigests.digest; 4 | 5 | import java.util.Arrays; 6 | import java.util.concurrent.ThreadLocalRandom; 7 | 8 | import jakarta.persistence.Column; 9 | import jakarta.persistence.Entity; 10 | import jakarta.persistence.ManyToOne; 11 | import jakarta.validation.constraints.NotNull; 12 | 13 | import org.omnifaces.persistence.model.GeneratedIdEntity; 14 | 15 | @Entity 16 | public class Credentials extends GeneratedIdEntity { 17 | 18 | private static final long serialVersionUID = 1L; 19 | 20 | private static final int HASH_LENGTH = 32; 21 | private static final int SALT_LENGTH = 40; 22 | 23 | @ManyToOne(optional = false) 24 | private @NotNull Person person; 25 | 26 | @Column(length = HASH_LENGTH, nullable = false) 27 | private @NotNull byte[] passwordHash; 28 | 29 | @Column(length = SALT_LENGTH, nullable = false) 30 | private @NotNull byte[] salt = new byte[SALT_LENGTH]; 31 | 32 | public void setPerson(Person person) { 33 | person.setCredentials(this); 34 | this.person = person; 35 | } 36 | 37 | public void setPassword(String password) { 38 | ThreadLocalRandom.current().nextBytes(salt); 39 | passwordHash = hash(password); 40 | } 41 | 42 | public boolean isValid(String password) { 43 | return Arrays.equals(passwordHash, hash(password)); 44 | } 45 | 46 | private byte[] hash(String password) { 47 | return digest(password, salt, "SHA-256"); 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/Group.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model; 2 | 3 | import static java.util.Arrays.asList; 4 | import static java.util.Collections.unmodifiableList; 5 | import static java.util.stream.Collectors.toList; 6 | import static org.example.kickoff.model.Role.ACCESS_API; 7 | import static org.example.kickoff.model.Role.EDIT_OWN_PROFILE; 8 | import static org.example.kickoff.model.Role.EDIT_PROFILES; 9 | import static org.example.kickoff.model.Role.VIEW_ADMIN_PAGES; 10 | import static org.example.kickoff.model.Role.VIEW_USER_PAGES; 11 | 12 | import java.util.Arrays; 13 | import java.util.List; 14 | 15 | public enum Group { 16 | 17 | USER(VIEW_USER_PAGES, EDIT_OWN_PROFILE, ACCESS_API), 18 | 19 | ADMIN(VIEW_ADMIN_PAGES, EDIT_PROFILES); 20 | 21 | private final List roles; 22 | 23 | private Group(Role... roles) { 24 | this.roles = unmodifiableList(asList(roles)); 25 | } 26 | 27 | public List getRoles() { 28 | return roles; 29 | } 30 | 31 | public static List getByRole(Role role) { 32 | return Arrays.stream(values()) 33 | .filter(group -> group.getRoles().contains(role)) 34 | .collect(toList()); 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/LoginToken.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model; 2 | 3 | import static jakarta.persistence.EnumType.STRING; 4 | import static java.time.temporal.ChronoUnit.MONTHS; 5 | 6 | import java.time.Instant; 7 | 8 | import jakarta.persistence.Column; 9 | import jakarta.persistence.Entity; 10 | import jakarta.persistence.Enumerated; 11 | import jakarta.persistence.ManyToOne; 12 | import jakarta.persistence.PrePersist; 13 | import jakarta.validation.constraints.NotNull; 14 | import jakarta.validation.constraints.Size; 15 | 16 | import org.omnifaces.persistence.model.GeneratedIdEntity; 17 | 18 | @Entity 19 | public class LoginToken extends GeneratedIdEntity { 20 | 21 | private static final long serialVersionUID = 1L; 22 | 23 | private static final int HASH_LENGTH = 32; 24 | public static final int IP_ADDRESS_MAXLENGTH = 45; 25 | public static final int DESCRIPTION_MAXLENGTH = 255; 26 | 27 | public enum TokenType { 28 | REMEMBER_ME, 29 | API, 30 | RESET_PASSWORD 31 | } 32 | 33 | @Column(length = HASH_LENGTH, nullable = false, unique = true) 34 | private @NotNull byte[] tokenHash; 35 | 36 | @Column(nullable = false) 37 | private @NotNull Instant created; 38 | 39 | @Column(nullable = false) 40 | private @NotNull Instant expiration; 41 | 42 | @Column(length = IP_ADDRESS_MAXLENGTH, nullable = false) 43 | private @NotNull @Size(max = IP_ADDRESS_MAXLENGTH) String ipAddress; 44 | 45 | @Column(length = DESCRIPTION_MAXLENGTH) 46 | private @Size(max = DESCRIPTION_MAXLENGTH) String description; 47 | 48 | @ManyToOne(optional = false) 49 | private Person person; 50 | 51 | @Enumerated(STRING) 52 | private TokenType type; 53 | 54 | public Person getPerson() { 55 | return person; 56 | } 57 | 58 | public void setPerson(Person person) { 59 | this.person = person; 60 | } 61 | 62 | public byte[] getTokenHash() { 63 | return tokenHash; 64 | } 65 | 66 | public void setTokenHash(byte[] tokenHash) { 67 | this.tokenHash = tokenHash; 68 | } 69 | 70 | public Instant getCreated() { 71 | return created; 72 | } 73 | 74 | public void setCreated(Instant created) { 75 | this.created = created; 76 | } 77 | 78 | public Instant getExpiration() { 79 | return expiration; 80 | } 81 | 82 | public void setExpiration(Instant expiration) { 83 | this.expiration = expiration; 84 | } 85 | 86 | public String getIpAddress() { 87 | return ipAddress; 88 | } 89 | 90 | public void setIpAddress(String ipAddress) { 91 | this.ipAddress = ipAddress; 92 | } 93 | 94 | public String getDescription() { 95 | return description; 96 | } 97 | 98 | public void setDescription(String description) { 99 | this.description = description; 100 | } 101 | 102 | public TokenType getType() { 103 | return type; 104 | } 105 | 106 | public void setType(TokenType type) { 107 | this.type = type; 108 | } 109 | 110 | @PrePersist 111 | public void setTimestamps() { 112 | created = Instant.now(); 113 | 114 | if (expiration == null) { 115 | expiration = created.plus(1, MONTHS); 116 | } 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/Person.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model; 2 | 3 | import static jakarta.persistence.CascadeType.ALL; 4 | import static jakarta.persistence.EnumType.STRING; 5 | import static jakarta.persistence.FetchType.EAGER; 6 | import static jakarta.persistence.FetchType.LAZY; 7 | import static java.util.stream.Collectors.toSet; 8 | 9 | import java.time.Instant; 10 | import java.util.ArrayList; 11 | import java.util.HashSet; 12 | import java.util.List; 13 | import java.util.Set; 14 | 15 | import jakarta.persistence.Column; 16 | import jakarta.persistence.ElementCollection; 17 | import jakarta.persistence.Entity; 18 | import jakarta.persistence.Enumerated; 19 | import jakarta.persistence.OneToMany; 20 | import jakarta.persistence.OneToOne; 21 | import jakarta.persistence.Transient; 22 | import jakarta.validation.constraints.NotNull; 23 | import jakarta.validation.constraints.Size; 24 | 25 | import org.example.kickoff.model.validator.Email; 26 | import org.omnifaces.persistence.model.TimestampedEntity; 27 | 28 | @Entity 29 | public class Person extends TimestampedEntity { 30 | 31 | private static final long serialVersionUID = 1L; 32 | 33 | public static final int EMAIL_MAXLENGTH = 254; 34 | public static final int NAME_MAXLENGTH = 32; 35 | 36 | @Column(length = EMAIL_MAXLENGTH, nullable = false, unique = true) 37 | private @NotNull @Size(max = EMAIL_MAXLENGTH) @Email String email; 38 | 39 | @Column(length = NAME_MAXLENGTH, nullable = false) 40 | private @NotNull @Size(max = NAME_MAXLENGTH) String firstName; 41 | 42 | @Column(length = NAME_MAXLENGTH, nullable = false) 43 | private @NotNull @Size(max = NAME_MAXLENGTH) String lastName; 44 | 45 | private String fullName; 46 | 47 | /* 48 | * TODO: implement. 49 | */ 50 | @Column(nullable = false) 51 | private boolean emailVerified = true; // For now. 52 | 53 | @OneToOne(mappedBy = "person", fetch = LAZY, cascade = ALL) 54 | private Credentials credentials; 55 | 56 | @OneToMany(mappedBy = "person", fetch = LAZY, cascade = ALL, orphanRemoval = true) 57 | private List loginTokens = new ArrayList<>(); 58 | 59 | @ElementCollection(fetch = EAGER) 60 | private @Enumerated(STRING) Set groups = new HashSet<>(); 61 | 62 | @Column 63 | private Instant lastLogin; 64 | 65 | public String getEmail() { 66 | return email; 67 | } 68 | 69 | public void setEmail(String email) { 70 | this.email = email; 71 | } 72 | 73 | public String getFirstName() { 74 | return firstName; 75 | } 76 | 77 | public void setFirstName(String firstName) { 78 | this.firstName = firstName; 79 | } 80 | 81 | public String getLastName() { 82 | return lastName; 83 | } 84 | 85 | public void setLastName(String lastName) { 86 | this.lastName = lastName; 87 | } 88 | 89 | public String getFullName() { 90 | return fullName; 91 | } 92 | 93 | public boolean isEmailVerified() { 94 | return emailVerified; 95 | } 96 | 97 | public void setEmailVerified(boolean emailVerified) { 98 | this.emailVerified = emailVerified; 99 | } 100 | 101 | public Credentials getCredentials() { 102 | return credentials; 103 | } 104 | 105 | public void setCredentials(Credentials credentials) { 106 | this.credentials = credentials; 107 | } 108 | 109 | public List getLoginTokens() { 110 | return loginTokens; 111 | } 112 | 113 | public Set getGroups() { 114 | return groups; 115 | } 116 | 117 | public void setGroups(Set groups) { 118 | this.groups = groups; 119 | } 120 | 121 | public Instant getLastLogin() { 122 | return lastLogin; 123 | } 124 | 125 | public void setLastLogin(Instant lastLogin) { 126 | this.lastLogin = lastLogin; 127 | } 128 | 129 | @Transient 130 | public Set getRoles() { 131 | return groups.stream().flatMap(g -> g.getRoles().stream()).collect(toSet()); 132 | } 133 | 134 | @Transient 135 | public Set getRolesAsStrings() { 136 | return getRoles().stream().map(Role::name).collect(toSet()); 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/Role.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model; 2 | 3 | public enum Role { 4 | 5 | // Viewing stuff. 6 | VIEW_USER_PAGES, 7 | VIEW_ADMIN_PAGES, 8 | 9 | // Editing stuff. 10 | EDIT_OWN_PROFILE, 11 | EDIT_PROFILES, 12 | 13 | // Actions. 14 | ACCESS_API; 15 | 16 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/producer/LoggerProducer.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model.producer; 2 | 3 | import java.util.logging.Logger; 4 | 5 | import jakarta.enterprise.context.Dependent; 6 | import jakarta.enterprise.inject.Produces; 7 | import jakarta.enterprise.inject.spi.InjectionPoint; 8 | 9 | @Dependent 10 | public class LoggerProducer { 11 | 12 | @Produces 13 | public Logger getLogger(InjectionPoint injectionPoint) { 14 | return Logger.getLogger(injectionPoint.getMember().getDeclaringClass().getName()); 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/validator/Email.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model.validator; 2 | 3 | import static java.lang.annotation.ElementType.FIELD; 4 | import static java.lang.annotation.ElementType.METHOD; 5 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 6 | 7 | import java.lang.annotation.Documented; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.Target; 10 | 11 | import jakarta.validation.Constraint; 12 | import jakarta.validation.Payload; 13 | import jakarta.validation.constraints.Size; 14 | 15 | @Constraint(validatedBy = EmailValidator.class) 16 | @Size(max = 254, message = "{invalid.email}") 17 | @Documented 18 | @Target({ METHOD, FIELD }) 19 | @Retention(RUNTIME) 20 | public @interface Email { 21 | 22 | String message() default "{invalid.email}"; 23 | 24 | Class[] groups() default {}; 25 | 26 | Class[] payload() default {}; 27 | 28 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/validator/EmailValidator.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model.validator; 2 | 3 | import jakarta.mail.internet.AddressException; 4 | import jakarta.mail.internet.InternetAddress; 5 | import jakarta.validation.ConstraintValidator; 6 | import jakarta.validation.ConstraintValidatorContext; 7 | 8 | public class EmailValidator implements ConstraintValidator { 9 | 10 | @Override 11 | public void initialize(Email constraintAnnotation) { 12 | // 13 | } 14 | 15 | @Override 16 | public boolean isValid(String email, ConstraintValidatorContext context) { 17 | if (email == null) { 18 | return true; // Let @NotNull handle this. 19 | } 20 | 21 | try { 22 | new InternetAddress(email).validate(); 23 | } 24 | catch (AddressException e) { 25 | return false; 26 | } 27 | 28 | return true; 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/validator/Password.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model.validator; 2 | 3 | import static java.lang.annotation.ElementType.FIELD; 4 | import static java.lang.annotation.ElementType.METHOD; 5 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 6 | 7 | import java.lang.annotation.Documented; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.Target; 10 | 11 | import jakarta.validation.Constraint; 12 | import jakarta.validation.Payload; 13 | 14 | @Constraint(validatedBy = PasswordValidator.class) 15 | @Documented 16 | @Target({ METHOD, FIELD }) 17 | @Retention(RUNTIME) 18 | public @interface Password { 19 | 20 | String message() default "{invalid.password}"; 21 | 22 | Class[] groups() default {}; 23 | 24 | Class[] payload() default {}; 25 | 26 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/model/validator/PasswordValidator.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.model.validator; 2 | 3 | import jakarta.validation.ConstraintValidator; 4 | import jakarta.validation.ConstraintValidatorContext; 5 | 6 | public class PasswordValidator implements ConstraintValidator { 7 | 8 | @Override 9 | public void initialize(Password constraintAnnotation) { 10 | // 11 | } 12 | 13 | @Override 14 | public boolean isValid(String password, ConstraintValidatorContext context) { 15 | if (password == null) { 16 | return true; // Let @NotNull handle this. 17 | } 18 | 19 | return password.length() >= 8 20 | && password.chars() 21 | .filter(c -> !isLatinLetter(c)) 22 | .findFirst() 23 | .isPresent(); 24 | } 25 | 26 | private static boolean isLatinLetter(int c) { 27 | return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/ActiveLocale.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view; 2 | 3 | import static java.lang.String.format; 4 | import static java.util.Collections.list; 5 | import static java.util.Optional.ofNullable; 6 | import static java.util.function.Function.identity; 7 | import static java.util.stream.Collectors.toList; 8 | import static java.util.stream.Collectors.toMap; 9 | import static org.omnifaces.util.Servlets.getRequestCookie; 10 | import static org.omnifaces.util.Servlets.getRequestURI; 11 | import static org.omnifaces.util.Servlets.getRequestURIWithQueryString; 12 | import static org.omnifaces.utils.Lang.coalesce; 13 | 14 | import java.util.LinkedHashMap; 15 | import java.util.List; 16 | import java.util.Locale; 17 | import java.util.Objects; 18 | 19 | import org.example.kickoff.view.filter.LocaleFilter; 20 | import org.example.kickoff.view.viewhandler.LocaleAwareViewHandler; 21 | import org.omnifaces.config.FacesConfigXml; 22 | 23 | import jakarta.annotation.PostConstruct; 24 | import jakarta.enterprise.context.RequestScoped; 25 | import jakarta.inject.Inject; 26 | import jakarta.inject.Named; 27 | import jakarta.servlet.http.HttpServletRequest; 28 | 29 | /** 30 | * @see LocaleFilter 31 | * @see LocaleAwareViewHandler 32 | */ 33 | @Named 34 | @RequestScoped 35 | public class ActiveLocale { 36 | 37 | public static final String COOKIE_NAME = "locale"; 38 | public static final int COOKIE_MAX_AGE_IN_DAYS = 30; 39 | 40 | private static final Locale DEFAULT_LOCALE = FacesConfigXml.instance().getSupportedLocales().get(0); 41 | private static final LinkedHashMap SUPPORTED_LOCALES = collectSupportedLocales(); 42 | 43 | private Locale locale; 44 | private String name; 45 | private String languageTag; 46 | private String path; 47 | private List others; 48 | 49 | private ActiveLocale current; 50 | private boolean explicitlyRequested; 51 | private boolean defaultLocale; 52 | private boolean changed; 53 | private String uri; 54 | private String canonicalURLFormat; 55 | 56 | @Inject 57 | private HttpServletRequest request; 58 | 59 | public ActiveLocale() { 60 | // Keep default c'tor alive for CDI. 61 | } 62 | 63 | private ActiveLocale(Locale locale) { 64 | this.locale = locale; 65 | name = locale.getDisplayLanguage(locale); 66 | languageTag = locale.toLanguageTag(); 67 | path = locale.equals(DEFAULT_LOCALE) ? "" : ("/" + languageTag); 68 | current = this; 69 | } 70 | 71 | @PostConstruct 72 | public void init() { 73 | Locale explicitlyRequestedLocale = getExplicitlyRequestedLocale(request); 74 | Locale cookieRememberedLocale = getCookieRememberedLocale(request); 75 | Locale clientPreferredLocale = ofNullable(cookieRememberedLocale).orElseGet(() -> getClientRequestedLocale(request)); 76 | 77 | current = SUPPORTED_LOCALES.get(coalesce(explicitlyRequestedLocale, clientPreferredLocale)); 78 | defaultLocale = current.locale.equals(DEFAULT_LOCALE); 79 | explicitlyRequested = explicitlyRequestedLocale != null; 80 | changed = explicitlyRequested && !clientPreferredLocale.equals(explicitlyRequestedLocale); 81 | uri = getRequestURIWithQueryString(request).substring(explicitlyRequested ? current.languageTag.length() + 1 : 0); 82 | canonicalURLFormat = request.getContextPath() + "/%s" + uri; 83 | } 84 | 85 | public Locale getValue() { 86 | return current.locale; 87 | } 88 | 89 | public String getName() { 90 | return current.name; 91 | } 92 | 93 | public String getLanguageTag() { 94 | return current.languageTag; 95 | } 96 | 97 | public String getPath() { 98 | return current.path; 99 | } 100 | 101 | public List getOthers() { 102 | return current.others; 103 | } 104 | 105 | public boolean isExplicitlyRequested() { 106 | return explicitlyRequested; 107 | } 108 | 109 | public boolean isDefaultLocale() { 110 | return defaultLocale; 111 | } 112 | 113 | public boolean isChanged() { 114 | return changed; 115 | } 116 | 117 | public String getUri() { 118 | return uri; 119 | } 120 | 121 | public String getCanonicalURL() { 122 | return format(canonicalURLFormat, current.languageTag); 123 | } 124 | 125 | public String canonicalURL(ActiveLocale otherLocale) { 126 | return format(canonicalURLFormat, otherLocale.languageTag); 127 | } 128 | 129 | private static LinkedHashMap collectSupportedLocales() { 130 | LinkedHashMap supportedLocales = FacesConfigXml.instance().getSupportedLocales().stream() 131 | .collect(toMap(identity(), ActiveLocale::new, (l, r) -> l, LinkedHashMap::new)); 132 | supportedLocales.values() 133 | .forEach(supportedLocale -> supportedLocale.others = supportedLocales.values().stream().filter(l -> !l.equals(supportedLocale)) 134 | .collect(toList())); 135 | return supportedLocales; 136 | } 137 | 138 | private static Locale getExplicitlyRequestedLocale(HttpServletRequest request) { 139 | String requestURI = getRequestURI(request) + "/"; 140 | return SUPPORTED_LOCALES.keySet().stream() 141 | .filter(supportedLocale -> requestURI.startsWith("/" + supportedLocale.toLanguageTag() + "/")) 142 | .findFirst().orElse(null); 143 | } 144 | 145 | private static Locale getCookieRememberedLocale(HttpServletRequest request) { 146 | String cookieRememberedLocale = getRequestCookie(request, COOKIE_NAME); 147 | return cookieRememberedLocale == null ? null : SUPPORTED_LOCALES.keySet().stream() 148 | .filter(supportedLocale -> supportedLocale.toLanguageTag().equals(cookieRememberedLocale)) 149 | .findFirst().orElse(null); 150 | } 151 | 152 | private static Locale getClientRequestedLocale(HttpServletRequest request) { 153 | return list(request.getLocales()).stream() 154 | .map(requestedLocale -> SUPPORTED_LOCALES.keySet().stream() 155 | .filter(supportedLocale -> matches(requestedLocale, supportedLocale)) 156 | .findFirst().orElse(null)) 157 | .filter(Objects::nonNull) 158 | .findFirst().orElse(DEFAULT_LOCALE); 159 | } 160 | 161 | private static boolean matches(Locale requestedLocale, Locale supportedLocale) { 162 | return supportedLocale.equals(requestedLocale) 163 | || (supportedLocale.getCountry().isEmpty() && requestedLocale.getLanguage().equals(supportedLocale.getLanguage())); 164 | } 165 | 166 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/ActiveUser.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view; 2 | 3 | import java.io.Serializable; 4 | import java.util.Map; 5 | import java.util.concurrent.ConcurrentHashMap; 6 | 7 | import jakarta.enterprise.context.SessionScoped; 8 | import jakarta.inject.Inject; 9 | import jakarta.inject.Named; 10 | import jakarta.security.enterprise.SecurityContext; 11 | 12 | import org.example.kickoff.config.auth.KickoffCallerPrincipal; 13 | import org.example.kickoff.model.Group; 14 | import org.example.kickoff.model.Person; 15 | import org.example.kickoff.model.Role; 16 | 17 | @Named 18 | @SessionScoped 19 | public class ActiveUser implements Serializable { 20 | 21 | private static final long serialVersionUID = 1L; 22 | 23 | private Map is = new ConcurrentHashMap<>(); 24 | private Map can = new ConcurrentHashMap<>(); 25 | private Map canView = new ConcurrentHashMap<>(); 26 | 27 | private Person activeUser; 28 | 29 | @Inject 30 | private SecurityContext securityContext; 31 | 32 | public Person get() { // For use in backing beans. 33 | if (activeUser == null) { 34 | activeUser = securityContext 35 | .getPrincipalsByType(KickoffCallerPrincipal.class).stream() 36 | .map(KickoffCallerPrincipal::getPerson) 37 | .findAny().orElse(null); 38 | } 39 | 40 | return activeUser; 41 | } 42 | 43 | public boolean isPresent() { // For use in both backing beans and EL #{activeUser.present} 44 | return get() != null; 45 | } 46 | 47 | public Long getId() { // For use in both backing beans and EL #{activeUser.id} 48 | return isPresent() ? activeUser.getId() : null; 49 | } 50 | 51 | public boolean hasGroup(Group group) { // For use in backing beans. 52 | return isPresent() && activeUser.getGroups().contains(group); 53 | } 54 | 55 | public boolean hasRole(Role role) { // For use in backing beans. 56 | return isPresent() && activeUser.getRoles().contains(role); 57 | } 58 | 59 | public boolean is(String group) { // For use in EL #{activeUser.is('ADMIN')} 60 | return isPresent() && is.computeIfAbsent(group, g -> activeUser.getGroups().stream().map(Group::name).anyMatch(g::equalsIgnoreCase)); 61 | } 62 | 63 | public boolean can(String role) { // For use in EL #{activeUser.can('VIEW_ADMIN_PAGES')} 64 | return isPresent() && can.computeIfAbsent(role, r -> activeUser.getRoles().stream().map(Role::name).anyMatch(r::equalsIgnoreCase)); 65 | } 66 | 67 | public boolean canView(String path) { // For use in EL #{activeUser.canView('admin/users')} 68 | return canView.computeIfAbsent(path, p -> securityContext.hasAccessToWebResource(p.startsWith("/") ? p : ("/" + p), "GET")); 69 | } 70 | 71 | @Override 72 | public String toString() { // Must print friendly name in EL #{activeUser}. 73 | return isPresent() ? activeUser.getFullName() : null; 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/Page.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view; 2 | 3 | import static org.omnifaces.util.Faces.getResource; 4 | import static org.omnifaces.util.Faces.getViewId; 5 | 6 | import java.util.Map; 7 | import java.util.concurrent.ConcurrentHashMap; 8 | 9 | import org.omnifaces.config.WebXml; 10 | 11 | import jakarta.annotation.PostConstruct; 12 | import jakarta.enterprise.context.Dependent; 13 | import jakarta.inject.Named; 14 | 15 | @Named 16 | @Dependent 17 | public class Page { 18 | 19 | private static final Map PAGES = new ConcurrentHashMap<>(); 20 | 21 | private Page current; 22 | private String path; 23 | private String name; 24 | private boolean home; 25 | 26 | public Page() { 27 | // Keep default c'tor alive for CDI. 28 | } 29 | 30 | private Page(String path) { 31 | this.path = path; 32 | String uri = "/" + path; 33 | 34 | while (!uri.isEmpty()) { 35 | try { 36 | getResource(uri + ".xhtml").toString(); 37 | break; 38 | } 39 | catch (Exception ignore) { 40 | uri = uri.substring(0, uri.lastIndexOf('/')); 41 | } 42 | } 43 | 44 | name = path.replaceFirst("WEB\\-INF/", "").replaceAll("\\W+", "_"); 45 | home = WebXml.instance().getWelcomeFiles().contains(path); 46 | current = this; 47 | } 48 | 49 | @PostConstruct 50 | public void init() { 51 | String viewId = getViewId(); 52 | current = get(viewId.substring(1, viewId.lastIndexOf('.'))); 53 | } 54 | 55 | public Page get(String path) { 56 | return PAGES.computeIfAbsent(path, k -> new Page(path)); 57 | } 58 | 59 | public boolean is(String path) { 60 | return path.equals(current.path); 61 | } 62 | 63 | public String getPath() { 64 | return current.path; 65 | } 66 | 67 | public String getName() { 68 | return current.name; 69 | } 70 | 71 | public boolean isHome() { 72 | return current.home; 73 | } 74 | 75 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/admin/UsersBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.admin; 2 | 3 | import static org.omnifaces.util.Messages.addGlobalWarn; 4 | 5 | import java.io.Serializable; 6 | 7 | import jakarta.annotation.PostConstruct; 8 | import jakarta.inject.Inject; 9 | import jakarta.inject.Named; 10 | 11 | import org.example.kickoff.business.service.PersonService; 12 | import org.example.kickoff.model.Person; 13 | import org.omnifaces.cdi.ViewScoped; 14 | import org.omnifaces.optimusfaces.model.PagedDataModel; 15 | 16 | @Named 17 | @ViewScoped 18 | public class UsersBacking implements Serializable { 19 | 20 | private static final long serialVersionUID = 1L; 21 | 22 | private PagedDataModel model; 23 | 24 | @Inject 25 | private PersonService personService; 26 | 27 | @PostConstruct 28 | public void init() { 29 | model = PagedDataModel.lazy(personService).build(); 30 | } 31 | 32 | public void delete(Person person) { 33 | // personService.delete(person); 34 | addGlobalWarn("This is just a demo, we won't actually delete users for now."); 35 | } 36 | 37 | public PagedDataModel getModel() { 38 | return model; 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/admin/users/EditUserBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.admin.users; 2 | 3 | import static org.omnifaces.util.Faces.redirect; 4 | 5 | import java.io.IOException; 6 | 7 | import jakarta.enterprise.context.RequestScoped; 8 | import jakarta.inject.Inject; 9 | import jakarta.inject.Named; 10 | 11 | import org.example.kickoff.business.service.PersonService; 12 | import org.example.kickoff.model.Person; 13 | import org.omnifaces.cdi.Param; 14 | 15 | @Named 16 | @RequestScoped 17 | public class EditUserBacking { 18 | 19 | @Inject @Param(name="id") 20 | private Person person; 21 | 22 | @Inject 23 | private PersonService personService; 24 | 25 | public void save() throws IOException { 26 | personService.update(person); 27 | redirect("admin/users"); 28 | } 29 | 30 | public Person getPerson() { 31 | return person; 32 | } 33 | 34 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/auth/AuthBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.auth; 2 | 3 | import static jakarta.security.enterprise.AuthenticationStatus.SEND_CONTINUE; 4 | import static jakarta.security.enterprise.AuthenticationStatus.SEND_FAILURE; 5 | import static org.example.kickoff.model.Group.ADMIN; 6 | import static org.example.kickoff.model.Group.USER; 7 | import static org.omnifaces.util.Faces.getRequest; 8 | import static org.omnifaces.util.Faces.getResponse; 9 | import static org.omnifaces.util.Faces.redirect; 10 | import static org.omnifaces.util.Faces.responseComplete; 11 | import static org.omnifaces.util.Faces.validationFailed; 12 | import static org.omnifaces.util.Messages.addFlashGlobalWarn; 13 | import static org.omnifaces.util.Messages.addGlobalError; 14 | 15 | import java.io.IOException; 16 | 17 | import jakarta.annotation.PostConstruct; 18 | import jakarta.inject.Inject; 19 | import jakarta.security.enterprise.AuthenticationStatus; 20 | import jakarta.security.enterprise.SecurityContext; 21 | import jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters; 22 | import jakarta.validation.constraints.NotNull; 23 | 24 | import org.example.kickoff.business.service.PersonService; 25 | import org.example.kickoff.model.Person; 26 | import org.example.kickoff.model.validator.Password; 27 | import org.example.kickoff.view.ActiveUser; 28 | 29 | public abstract class AuthBacking { 30 | 31 | protected Person person; 32 | protected @NotNull @Password String password; 33 | protected boolean rememberMe; 34 | 35 | @Inject 36 | protected PersonService personService; 37 | 38 | @Inject 39 | private SecurityContext securityContext; 40 | 41 | @Inject 42 | private ActiveUser activeUser; 43 | 44 | @PostConstruct 45 | public void init() { 46 | if (activeUser.isPresent()) { 47 | addFlashGlobalWarn("auth.message.warn.already_logged_in"); 48 | redirect("user/profile"); 49 | } 50 | else { 51 | person = new Person(); 52 | } 53 | } 54 | 55 | protected void authenticate(AuthenticationParameters parameters) throws IOException { 56 | AuthenticationStatus status = securityContext.authenticate(getRequest(), getResponse(), parameters); 57 | 58 | if (status == SEND_FAILURE) { 59 | addGlobalError("auth.message.error.failure"); 60 | validationFailed(); 61 | } 62 | else if (status == SEND_CONTINUE) { 63 | responseComplete(); // Prevent JSF from rendering a response so authentication mechanism can continue. 64 | } 65 | else if (activeUser.hasGroup(ADMIN)) { 66 | redirect("admin/users"); 67 | } 68 | else if (activeUser.hasGroup(USER)) { 69 | redirect("user/profile"); 70 | } 71 | else { 72 | redirect(""); 73 | } 74 | } 75 | 76 | public Person getPerson() { 77 | return person; 78 | } 79 | 80 | public String getPassword() { 81 | return password; 82 | } 83 | 84 | public void setPassword(String password) { 85 | this.password = password; 86 | } 87 | 88 | public boolean isRememberMe() { 89 | return rememberMe; 90 | } 91 | 92 | public void setRememberMe(boolean rememberMe) { 93 | this.rememberMe = rememberMe; 94 | } 95 | 96 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/auth/LoginBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.auth; 2 | 3 | import static jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters.withParams; 4 | 5 | import java.io.IOException; 6 | 7 | import jakarta.enterprise.context.RequestScoped; 8 | import jakarta.inject.Inject; 9 | import jakarta.inject.Named; 10 | import jakarta.security.enterprise.credential.UsernamePasswordCredential; 11 | 12 | import org.omnifaces.cdi.Param; 13 | 14 | @Named 15 | @RequestScoped 16 | public class LoginBacking extends AuthBacking { 17 | 18 | @Inject @Param(name = "continue") // Defined in @LoginToContinue of KickoffFormAuthenticationMechanism. 19 | private boolean loginToContinue; 20 | 21 | public void login() throws IOException { 22 | authenticate(withParams() 23 | .credential(new UsernamePasswordCredential(person.getEmail(), password)) 24 | .newAuthentication(!loginToContinue) 25 | .rememberMe(rememberMe)); 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/auth/LogoutBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.auth; 2 | 3 | import static org.omnifaces.util.Faces.invalidateSession; 4 | import static org.omnifaces.util.Faces.redirect; 5 | import static org.omnifaces.util.Messages.addFlashGlobalWarn; 6 | 7 | import java.io.IOException; 8 | 9 | import jakarta.enterprise.context.RequestScoped; 10 | import jakarta.inject.Named; 11 | import jakarta.servlet.ServletException; 12 | 13 | import org.omnifaces.util.Faces; 14 | 15 | @Named 16 | @RequestScoped 17 | public class LogoutBacking { 18 | 19 | public void logout() throws ServletException, IOException { 20 | Faces.logout(); 21 | invalidateSession(); 22 | addFlashGlobalWarn("auth.message.warn.logged_out"); 23 | redirect(""); 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/auth/ResetPasswordBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.auth; 2 | 3 | import static org.example.kickoff.model.LoginToken.TokenType.RESET_PASSWORD; 4 | import static org.omnifaces.util.Faces.getRemoteAddr; 5 | import static org.omnifaces.util.Faces.getRequestURL; 6 | import static org.omnifaces.util.Faces.redirect; 7 | import static org.omnifaces.util.Messages.addFlashGlobalInfo; 8 | import static org.omnifaces.util.Messages.addFlashGlobalWarn; 9 | import static org.omnifaces.util.Messages.addGlobalInfo; 10 | 11 | import java.io.IOException; 12 | import java.util.logging.Logger; 13 | 14 | import jakarta.annotation.PostConstruct; 15 | import jakarta.enterprise.context.RequestScoped; 16 | import jakarta.inject.Inject; 17 | import jakarta.inject.Named; 18 | 19 | import org.example.kickoff.business.service.PersonService; 20 | import org.omnifaces.cdi.Param; 21 | 22 | @Named 23 | @RequestScoped 24 | public class ResetPasswordBacking extends AuthBacking { 25 | 26 | @Inject @Param 27 | private String token; 28 | 29 | @Inject 30 | private PersonService personService; 31 | 32 | @Inject 33 | private Logger logger; 34 | 35 | @Override 36 | @PostConstruct 37 | public void init() { 38 | super.init(); 39 | 40 | if (token != null && !personService.findByLoginToken(token, RESET_PASSWORD).isPresent()) { 41 | addFlashGlobalWarn("reset_password.message.warn.invalid_token"); 42 | redirect("reset-password"); 43 | } 44 | } 45 | 46 | public void requestResetPassword() { 47 | String email = person.getEmail(); 48 | String ipAddress = getRemoteAddr(); 49 | 50 | try { 51 | personService.requestResetPassword(email, ipAddress, getRequestURL() + "?token=%s"); 52 | } 53 | catch (Exception e) { 54 | logger.warning(ipAddress + " made a failed attempt to reset password for email " + email + ": " + e); 55 | } 56 | 57 | addGlobalInfo("reset_password.message.info.email_sent"); // For security, show success message regardless of outcome. 58 | } 59 | 60 | public void saveNewPassword() throws IOException { 61 | personService.updatePassword(token, password); 62 | addFlashGlobalInfo("reset_password.message.info.password_changed"); 63 | redirect("user/profile"); 64 | } 65 | 66 | public String getToken() { 67 | return token; 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/auth/SignupBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.auth; 2 | 3 | import static jakarta.security.enterprise.authentication.mechanism.http.AuthenticationParameters.withParams; 4 | 5 | import java.io.IOException; 6 | 7 | import jakarta.enterprise.context.RequestScoped; 8 | import jakarta.inject.Named; 9 | import jakarta.security.enterprise.credential.CallerOnlyCredential; 10 | 11 | @Named 12 | @RequestScoped 13 | public class SignupBacking extends AuthBacking { 14 | 15 | public void signup() throws IOException { 16 | personService.register(person, password); 17 | authenticate(withParams().credential(new CallerOnlyCredential(person.getEmail()))); 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/composite/InputLocalDate.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.composite; 2 | 3 | import static java.lang.Boolean.TRUE; 4 | import static java.time.LocalDate.now; 5 | import static java.util.stream.Collectors.toList; 6 | import static java.util.stream.IntStream.iterate; 7 | import static java.util.stream.IntStream.range; 8 | import static org.omnifaces.util.Ajax.update; 9 | import static org.omnifaces.util.Components.getAttribute; 10 | import static org.omnifaces.utils.Lang.coalesce; 11 | 12 | import java.io.IOException; 13 | import java.time.LocalDate; 14 | import java.time.Month; 15 | import java.time.Year; 16 | import java.time.YearMonth; 17 | import java.util.List; 18 | 19 | import org.omnifaces.util.State; 20 | import org.primefaces.component.selectonemenu.SelectOneMenu; 21 | 22 | import jakarta.faces.component.FacesComponent; 23 | import jakarta.faces.component.NamingContainer; 24 | import jakarta.faces.component.UIInput; 25 | import jakarta.faces.component.UINamingContainer; 26 | import jakarta.faces.context.FacesContext; 27 | import jakarta.faces.convert.ConverterException; 28 | 29 | @FacesComponent("inputLocalDate") 30 | public class InputLocalDate extends UIInput implements NamingContainer { 31 | 32 | // Constants ------------------------------------------------------------------------------------------------------ 33 | 34 | private static final int DEFAULT_YEAR_RANGE = 100; // Default to -100y ~ +100y 35 | 36 | // Properties ----------------------------------------------------------------------------------------------------- 37 | 38 | private SelectOneMenu day; 39 | private SelectOneMenu month; 40 | private SelectOneMenu year; 41 | 42 | // Variables ------------------------------------------------------------------------------------------------------ 43 | 44 | private final State state = new State(getStateHelper()); 45 | 46 | public InputLocalDate() { 47 | super.setRequired(getAttribute(this, "required") == TRUE); 48 | } 49 | 50 | // Actions -------------------------------------------------------------------------------------------------------- 51 | 52 | @Override 53 | public String getFamily() { 54 | return UINamingContainer.COMPONENT_FAMILY; 55 | } 56 | 57 | /** 58 | * Invoked when Faces renders the input field. 59 | */ 60 | @Override 61 | public void encodeBegin(FacesContext context) throws IOException { 62 | LocalDate now = now(); 63 | LocalDate min = coalesce(getAttribute(this, "min"), now.minusYears(DEFAULT_YEAR_RANGE)); 64 | LocalDate max = coalesce(getAttribute(this, "max"), now.plusYears(DEFAULT_YEAR_RANGE)); 65 | LocalDate value = (LocalDate) getValue(); 66 | 67 | day.setValue(value != null ? value.getDayOfMonth() : null); 68 | month.setValue(value != null ? value.getMonthValue() : null); 69 | year.setValue(value != null ? value.getYear() : null); 70 | 71 | setYears(iterate(max.getYear(), i -> i - 1).limit(max.getYear() - min.getYear()).boxed().collect(toList())); 72 | setMonthsBasedOnCurrentYear(max); 73 | setDaysBasedOnCurrentMonth(max); 74 | 75 | super.encodeBegin(context); 76 | } 77 | 78 | private int setDaysBasedOnCurrentMonth(LocalDate max) { 79 | int maxDays = 31; 80 | 81 | if (max != null) { 82 | Integer currentMonth = (Integer) month.getValue(); 83 | 84 | if (currentMonth != null) { 85 | Integer currentYear = (Integer) year.getValue(); 86 | 87 | if (currentYear != null) { 88 | 89 | if (YearMonth.of(currentYear, currentMonth).equals(YearMonth.from(max))) { 90 | maxDays = max.getDayOfMonth(); 91 | } 92 | else { 93 | maxDays = Month.of(currentMonth).length(Year.isLeap(currentYear)); 94 | } 95 | } 96 | } 97 | } 98 | 99 | setDays(range(1, maxDays + 1).boxed().collect(toList())); 100 | return maxDays; 101 | } 102 | 103 | private int setMonthsBasedOnCurrentYear(LocalDate max) { 104 | int maxMonths = Month.values().length; 105 | 106 | if (max != null) { 107 | Integer currentYear = (Integer) year.getValue(); 108 | 109 | if (currentYear != null && currentYear == max.getYear()) { 110 | maxMonths = max.getMonthValue(); 111 | } 112 | } 113 | 114 | setMonths(range(1, maxMonths + 1).boxed().collect(toList())); 115 | return maxMonths; 116 | } 117 | 118 | /** 119 | * Invoked when month dropdown is changed. 120 | */ 121 | public void updateDaysIfNecessary() { 122 | int oldMaxDays = getDays().size(); 123 | int newMaxDays = setDaysBasedOnCurrentMonth(getAttribute(this, "max")); 124 | 125 | if (oldMaxDays != newMaxDays) { 126 | Integer currentDay = (Integer) day.getValue(); 127 | 128 | if (currentDay != null && currentDay > newMaxDays) { 129 | day.setValue(newMaxDays); 130 | } 131 | 132 | update(day.getClientId()); 133 | } 134 | } 135 | 136 | /** 137 | * Invoked when year dropdown is changed. 138 | */ 139 | public void updateMonthsIfNecessary() { 140 | int oldMaxMonths = getMonths().size(); 141 | int newMaxMonths = setMonthsBasedOnCurrentYear(getAttribute(this, "max")); 142 | 143 | if (oldMaxMonths != newMaxMonths) { 144 | Integer currentMonth = (Integer) month.getValue(); 145 | 146 | if (currentMonth != null && currentMonth > newMaxMonths) { 147 | month.setValue(newMaxMonths); 148 | } 149 | 150 | update(month.getClientId()); 151 | } 152 | 153 | updateDaysIfNecessary(); 154 | } 155 | 156 | /** 157 | * Invoked when form is submitted. 158 | */ 159 | @Override 160 | public Object getSubmittedValue() { 161 | return year.getSubmittedValue() + "-" + month.getSubmittedValue() + "-" + day.getSubmittedValue(); 162 | } 163 | 164 | @Override 165 | protected Object getConvertedValue(FacesContext context, Object submittedValue) throws ConverterException { 166 | try { 167 | String[] yearMonthAndDay = ((String) submittedValue).split("-"); 168 | return LocalDate.of(Integer.valueOf(yearMonthAndDay[0]), Integer.valueOf(yearMonthAndDay[1]), Integer.valueOf(yearMonthAndDay[2])); 169 | } 170 | catch (Exception e) { 171 | return null; 172 | } 173 | } 174 | 175 | @Override 176 | public void setValid(boolean valid) { 177 | day.setValid(valid); 178 | month.setValid(valid); 179 | year.setValid(valid); 180 | super.setValid(valid); 181 | } 182 | 183 | // Getters/setters ------------------------------------------------------------------------------------------------ 184 | 185 | public SelectOneMenu getDay() { 186 | return day; 187 | } 188 | 189 | public void setDay(SelectOneMenu day) { 190 | this.day = day; 191 | } 192 | 193 | public List getDays() { 194 | return state.get("days"); 195 | } 196 | 197 | public void setDays(List days) { 198 | state.put("days", days); 199 | } 200 | 201 | public SelectOneMenu getMonth() { 202 | return month; 203 | } 204 | 205 | public void setMonth(SelectOneMenu month) { 206 | this.month = month; 207 | } 208 | 209 | public List getMonths() { 210 | return state.get("months"); 211 | } 212 | 213 | public void setMonths(List months) { 214 | state.put("months", months); 215 | } 216 | 217 | public SelectOneMenu getYear() { 218 | return year; 219 | } 220 | 221 | public void setYear(SelectOneMenu year) { 222 | this.year = year; 223 | } 224 | 225 | public List getYears() { 226 | return state.get("years"); 227 | } 228 | 229 | public void setYears(List years) { 230 | state.put("years", years); 231 | } 232 | 233 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/converter/BaseEntitySelectItemsConverter.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.converter; 2 | 3 | import java.util.Objects; 4 | 5 | import jakarta.faces.component.UIComponent; 6 | import jakarta.faces.context.FacesContext; 7 | import jakarta.faces.convert.FacesConverter; 8 | 9 | import org.omnifaces.converter.SelectItemsConverter; 10 | import org.omnifaces.persistence.model.BaseEntity; 11 | 12 | @FacesConverter("baseEntitySelectItemsConverter") 13 | public class BaseEntitySelectItemsConverter extends SelectItemsConverter { 14 | 15 | @Override 16 | public String getAsString(FacesContext context, UIComponent component, Object value) { 17 | if (value instanceof BaseEntity) { 18 | Object id = ((BaseEntity) value).getId(); 19 | return Objects.toString(id, ""); 20 | } 21 | else { 22 | return super.getAsString(context, component, value); 23 | } 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/converter/LocalDateConverter.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.converter; 2 | import static org.omnifaces.util.Messages.createError; 3 | import static org.omnifaces.util.Utils.coalesce; 4 | import static org.omnifaces.util.Utils.parseLocale; 5 | 6 | import java.time.LocalDate; 7 | import java.time.format.DateTimeFormatter; 8 | import java.time.format.DateTimeParseException; 9 | import java.util.Locale; 10 | 11 | import jakarta.faces.component.UIComponent; 12 | import jakarta.faces.context.FacesContext; 13 | import jakarta.faces.convert.Converter; 14 | import jakarta.faces.convert.ConverterException; 15 | import jakarta.faces.convert.FacesConverter; 16 | 17 | import org.omnifaces.util.FacesLocal; 18 | 19 | @FacesConverter("localDateConverter") 20 | public class LocalDateConverter implements Converter { 21 | 22 | @Override 23 | public String getAsString(FacesContext context, UIComponent component, Object modelValue) { 24 | if (modelValue == null) { 25 | return ""; 26 | } 27 | 28 | if (modelValue instanceof LocalDate) { 29 | return getFormatter(context, component).format((LocalDate) modelValue); 30 | } 31 | else { 32 | throw new IllegalArgumentException("This converter can only be used on LocalDate."); 33 | } 34 | } 35 | 36 | @Override 37 | public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) { 38 | if (submittedValue == null || submittedValue.isEmpty()) { 39 | return null; 40 | } 41 | 42 | try { 43 | return LocalDate.parse(submittedValue, getFormatter(context, component)); 44 | } 45 | catch (DateTimeParseException e) { 46 | throw new ConverterException(createError("localDateConverter", submittedValue), e); 47 | } 48 | } 49 | 50 | private DateTimeFormatter getFormatter(FacesContext context, UIComponent component) { 51 | return DateTimeFormatter.ofPattern(getPattern(component), getLocale(context, component)); 52 | } 53 | 54 | private String getPattern(UIComponent component) { 55 | String pattern = (String) component.getAttributes().get("pattern"); 56 | 57 | if (pattern == null) { 58 | throw new IllegalArgumentException("The 'pattern' attribute is required"); 59 | } 60 | 61 | return pattern; 62 | } 63 | 64 | private Locale getLocale(FacesContext context, UIComponent component) { 65 | return coalesce(parseLocale(component.getAttributes().get("locale")), FacesLocal.getLocale(context)); 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/converter/PersonConverter.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.converter; 2 | 3 | import jakarta.faces.component.UIComponent; 4 | import jakarta.faces.context.FacesContext; 5 | import jakarta.faces.convert.Converter; 6 | import jakarta.faces.convert.ConverterException; 7 | import jakarta.faces.convert.FacesConverter; 8 | import jakarta.inject.Inject; 9 | 10 | import org.example.kickoff.business.service.PersonService; 11 | import org.example.kickoff.model.Person; 12 | 13 | @FacesConverter(forClass=Person.class) 14 | public class PersonConverter implements Converter { 15 | 16 | @Inject 17 | private PersonService personService; 18 | 19 | @Override 20 | public String getAsString(FacesContext context, UIComponent component, Object modelValue) { 21 | if (modelValue == null) { 22 | return ""; 23 | } 24 | 25 | if (modelValue instanceof Person) { 26 | return ((Person) modelValue).getId().toString(); 27 | } 28 | else { 29 | throw new ConverterException(modelValue + " is not a Person"); 30 | } 31 | } 32 | 33 | @Override 34 | public Object getAsObject(FacesContext context, UIComponent component, String submittedValue) { 35 | if (submittedValue == null) { 36 | return null; 37 | } 38 | 39 | try { 40 | return personService.getById(Long.valueOf(submittedValue)); 41 | } 42 | catch (NumberFormatException e) { 43 | throw new ConverterException(submittedValue + " is not a valid Person ID", e); 44 | } 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/filter/LocaleFilter.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.filter; 2 | 3 | import static java.util.concurrent.TimeUnit.DAYS; 4 | import static org.example.kickoff.view.ActiveLocale.COOKIE_MAX_AGE_IN_DAYS; 5 | import static org.example.kickoff.view.ActiveLocale.COOKIE_NAME; 6 | import static org.omnifaces.util.Servlets.addResponseCookie; 7 | import java.io.IOException; 8 | import jakarta.inject.Inject; 9 | import jakarta.servlet.FilterChain; 10 | import jakarta.servlet.ServletException; 11 | import jakarta.servlet.annotation.WebFilter; 12 | import jakarta.servlet.http.HttpServletRequest; 13 | import jakarta.servlet.http.HttpServletResponse; 14 | import jakarta.servlet.http.HttpSession; 15 | 16 | import org.example.kickoff.view.ActiveLocale; 17 | import org.omnifaces.filter.HttpFilter; 18 | import org.omnifaces.util.Servlets; 19 | 20 | @WebFilter(filterName = "localeFilter") 21 | public class LocaleFilter extends HttpFilter { 22 | 23 | @Inject 24 | private ActiveLocale activeLocale; 25 | 26 | @Override 27 | public void doFilter(HttpServletRequest request, HttpServletResponse response, HttpSession session, FilterChain chain) throws ServletException, IOException { 28 | 29 | if (Servlets.isFacesResourceRequest(request)) { 30 | chain.doFilter(request, response); 31 | } 32 | else if (activeLocale.isExplicitlyRequested()) { 33 | if (activeLocale.isChanged() && !isFacesEvent(request)) { // Never set locale cookie on ajax or unload events. 34 | addResponseCookie(request, response, COOKIE_NAME, activeLocale.getLanguageTag(), "/", (int) DAYS.toSeconds(COOKIE_MAX_AGE_IN_DAYS)); 35 | } 36 | 37 | if (activeLocale.isDefaultLocale()) { 38 | response.sendRedirect(request.getContextPath() + activeLocale.getUri()); 39 | } 40 | else { 41 | request.getRequestDispatcher(activeLocale.getUri()).forward(request, response); 42 | } 43 | } 44 | else if (!activeLocale.isDefaultLocale()) { 45 | response.sendRedirect(request.getContextPath() + activeLocale.getPath() + activeLocale.getUri()); 46 | } 47 | else { 48 | chain.doFilter(request, response); 49 | } 50 | } 51 | 52 | private static boolean isFacesEvent(HttpServletRequest request) { 53 | return request.getParameter("jakarta.faces.behavior.event") != null || request.getParameter("omnifaces.event") != null; 54 | } 55 | 56 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/phaselistener/FacesRequestLoggerPatch.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.phaselistener; 2 | 3 | import static jakarta.faces.component.behavior.ClientBehaviorContext.BEHAVIOR_EVENT_PARAM_NAME; 4 | import static org.omnifaces.cdi.viewscope.ViewScopeManager.isUnloadRequest; 5 | import static org.omnifaces.config.OmniFaces.OMNIFACES_EVENT_PARAM_NAME; 6 | import static org.omnifaces.util.Components.getActionExpressionsAndListeners; 7 | import static org.omnifaces.util.Components.getCurrentActionSource; 8 | import static org.omnifaces.util.FacesLocal.getRequestParameter; 9 | import static org.omnifaces.util.Utils.coalesce; 10 | 11 | import java.util.LinkedHashMap; 12 | import java.util.Map; 13 | 14 | import jakarta.faces.component.UIComponent; 15 | import jakarta.faces.context.FacesContext; 16 | 17 | import org.omnifaces.eventlistener.FacesRequestLogger; 18 | 19 | /** 20 | * Patch FacesRequestLogger to not throw NPE on unload requests. Will be fixed in OmniFaces 3.3. 21 | */ 22 | public class FacesRequestLoggerPatch extends FacesRequestLogger { 23 | 24 | private static final long serialVersionUID = 1L; 25 | 26 | @Override 27 | protected Map getActionDetails(FacesContext context) { 28 | UIComponent actionSource = isUnloadRequest(context) ? null : getCurrentActionSource(); 29 | Map actionDetails = new LinkedHashMap<>(); 30 | actionDetails.put("source", actionSource != null ? actionSource.getClientId(context) : null); 31 | actionDetails.put("event", coalesce(getRequestParameter(context, BEHAVIOR_EVENT_PARAM_NAME), getRequestParameter(context, OMNIFACES_EVENT_PARAM_NAME))); 32 | actionDetails.put("methods", getActionExpressionsAndListeners(actionSource)); 33 | actionDetails.put("validationFailed", context.isValidationFailed()); 34 | return actionDetails; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/resourcehandler/ResourceHandlerImplPatch.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.resourcehandler; 2 | 3 | import java.net.MalformedURLException; 4 | 5 | import jakarta.faces.FacesException; 6 | import jakarta.faces.application.Resource; 7 | import jakarta.faces.application.ResourceHandler; 8 | 9 | import org.omnifaces.resourcehandler.DefaultResourceHandler; 10 | import org.omnifaces.util.Faces; 11 | 12 | /** 13 | * Patches Mojarra bug of unnecessarily logging as below when it's about to locate a tagfile. 14 | * "WARNING JSF1064: Unable to find or serve resource from library, composites" 15 | */ 16 | public class ResourceHandlerImplPatch extends DefaultResourceHandler { 17 | 18 | private static final String COMPOSITE_LIBRARY_NAME = "composites"; 19 | 20 | public ResourceHandlerImplPatch(ResourceHandler wrapped) { 21 | super(wrapped); 22 | } 23 | 24 | @Override 25 | public String getLibraryName() { 26 | return COMPOSITE_LIBRARY_NAME; 27 | } 28 | 29 | @Override 30 | public Resource createResourceFromLibrary(String resourceName, String contentType) { 31 | try { 32 | if (Faces.getResource("/resources/" + COMPOSITE_LIBRARY_NAME + "/" + resourceName) != null) { 33 | return getWrapped().createResource(resourceName, COMPOSITE_LIBRARY_NAME, contentType); 34 | } 35 | else { 36 | return null; // So ResourceHandlerImpl with its unnecessary logging will be skipped. 37 | } 38 | } 39 | catch (MalformedURLException e) { 40 | throw new FacesException(e); 41 | } 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/servlet/ScriptErrorLogger.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.servlet; 2 | 3 | import static org.omnifaces.util.Servlets.getRequestParameterMap; 4 | import static org.omnifaces.util.Servlets.getRequestURI; 5 | 6 | import java.io.IOException; 7 | import java.util.logging.Logger; 8 | 9 | import jakarta.inject.Inject; 10 | import jakarta.servlet.ServletException; 11 | import jakarta.servlet.annotation.WebServlet; 12 | import jakarta.servlet.http.HttpServlet; 13 | import jakarta.servlet.http.HttpServletRequest; 14 | import jakarta.servlet.http.HttpServletResponse; 15 | 16 | /** 17 | * This is triggered by window.onerror in onload.js. 18 | */ 19 | @WebServlet("/script-error") 20 | public class ScriptErrorLogger extends HttpServlet { 21 | 22 | private static final long serialVersionUID = 1L; 23 | 24 | @Inject 25 | private Logger logger; 26 | 27 | @Override 28 | protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 29 | logger.warning("Script error: " + getRequestURI(request) + ", " + getRequestParameterMap(request)); 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/user/ProfileBacking.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.user; 2 | 3 | import static org.omnifaces.util.Messages.addGlobalInfo; 4 | 5 | import jakarta.annotation.PostConstruct; 6 | import jakarta.enterprise.context.RequestScoped; 7 | import jakarta.inject.Inject; 8 | import jakarta.inject.Named; 9 | import jakarta.validation.constraints.NotNull; 10 | 11 | import org.example.kickoff.business.service.PersonService; 12 | import org.example.kickoff.model.Person; 13 | import org.example.kickoff.model.validator.Password; 14 | import org.example.kickoff.view.ActiveUser; 15 | 16 | @Named 17 | @RequestScoped 18 | public class ProfileBacking { 19 | 20 | private Person person; 21 | private @NotNull @Password String currentPassword; 22 | private @NotNull @Password String newPassword; 23 | 24 | @Inject 25 | private ActiveUser activeUser; 26 | 27 | @Inject 28 | private PersonService personService; 29 | 30 | @PostConstruct 31 | public void init() { 32 | person = activeUser.get(); 33 | } 34 | 35 | public void save() { 36 | personService.update(person); 37 | addGlobalInfo("user_profile.message.info.account_updated"); 38 | } 39 | 40 | public void changePassword() { 41 | personService.updatePassword(person, newPassword); 42 | addGlobalInfo("user_profile.message.info.password_changed"); 43 | } 44 | 45 | public Person getPerson() { 46 | return person; 47 | } 48 | 49 | public String getCurrentPassword() { 50 | return currentPassword; 51 | } 52 | 53 | public void setCurrentPassword(String currentPassword) { 54 | this.currentPassword = currentPassword; 55 | } 56 | 57 | public String getNewPassword() { 58 | return newPassword; 59 | } 60 | 61 | public void setNewPassword(String newPassword) { 62 | this.newPassword = newPassword; 63 | } 64 | 65 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/validator/DuplicateEmailValidator.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.validator; 2 | 3 | import static org.omnifaces.util.Messages.createError; 4 | 5 | import java.util.Optional; 6 | 7 | import jakarta.faces.component.UIComponent; 8 | import jakarta.faces.context.FacesContext; 9 | import jakarta.faces.validator.FacesValidator; 10 | import jakarta.faces.validator.ValidatorException; 11 | import jakarta.inject.Inject; 12 | 13 | import org.example.kickoff.business.service.PersonService; 14 | import org.example.kickoff.model.Person; 15 | import org.omnifaces.validator.ValueChangeValidator; 16 | 17 | @FacesValidator("duplicateEmailValidator") 18 | public class DuplicateEmailValidator extends ValueChangeValidator { 19 | 20 | @Inject 21 | private PersonService personService; 22 | 23 | @Override 24 | public void validateChangedObject(FacesContext context, UIComponent component, String value) throws ValidatorException { 25 | if (value == null) { 26 | return; 27 | } 28 | 29 | Optional optionalPerson = personService.findByEmail(value); 30 | 31 | if (optionalPerson.isPresent()) { 32 | throw new ValidatorException(createError("duplicateEmailValidator")); 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/validator/EmailVerifiedValidator.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.validator; 2 | 3 | import static org.omnifaces.util.Messages.createError; 4 | 5 | import java.util.Optional; 6 | 7 | import jakarta.faces.component.UIComponent; 8 | import jakarta.faces.context.FacesContext; 9 | import jakarta.faces.validator.FacesValidator; 10 | import jakarta.faces.validator.Validator; 11 | import jakarta.faces.validator.ValidatorException; 12 | import jakarta.inject.Inject; 13 | 14 | import org.example.kickoff.business.service.PersonService; 15 | import org.example.kickoff.model.Person; 16 | 17 | @FacesValidator("emailVerifiedValidator") 18 | public class EmailVerifiedValidator implements Validator { 19 | 20 | @Inject 21 | private PersonService personService; 22 | 23 | @Override 24 | public void validate(FacesContext context, UIComponent component, String value) throws ValidatorException { 25 | if (value == null) { 26 | return; 27 | } 28 | 29 | Optional optionalPerson = personService.findByEmail(value); 30 | 31 | if (optionalPerson.isPresent() && !optionalPerson.get().isEmailVerified()) { 32 | throw new ValidatorException(createError("emailVerifiedValidator")); 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /src/main/java/org/example/kickoff/view/viewhandler/LocaleAwareViewHandler.java: -------------------------------------------------------------------------------- 1 | package org.example.kickoff.view.viewhandler; 2 | 3 | import static org.omnifaces.util.Beans.getReference; 4 | import static org.omnifaces.util.FacesLocal.getRequestContextPath; 5 | 6 | import jakarta.faces.application.ViewHandler; 7 | import jakarta.faces.application.ViewHandlerWrapper; 8 | import jakarta.faces.context.FacesContext; 9 | 10 | import org.example.kickoff.view.ActiveLocale; 11 | 12 | public class LocaleAwareViewHandler extends ViewHandlerWrapper { 13 | 14 | private ViewHandler wrapped; 15 | 16 | public LocaleAwareViewHandler(ViewHandler wrapped) { 17 | this.wrapped = wrapped; 18 | } 19 | 20 | @Override 21 | public String getActionURL(FacesContext context, String viewId) { 22 | String contextPath = getRequestContextPath(context); 23 | String localePath = getReference(ActiveLocale.class).getPath(); 24 | String uri = super.getActionURL(context, viewId).substring(contextPath.length()); 25 | return contextPath + localePath + uri; 26 | } 27 | 28 | @Override 29 | public ViewHandler getWrapped() { 30 | return wrapped; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/LoginToken.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | DELETE 11 | FROM 12 | LoginToken _loginToken 13 | WHERE 14 | _loginToken.tokenHash = :tokenHash 15 | 16 | 17 | 18 | 19 | 20 | DELETE 21 | FROM 22 | LoginToken _loginToken 23 | WHERE 24 | _loginToken.expiration < CURRENT_TIMESTAMP 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/Person.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | SELECT 11 | _person 12 | FROM 13 | Person _person 14 | WHERE 15 | _person.email = :email 16 | 17 | 18 | 19 | 20 | 21 | SELECT 22 | _person 23 | FROM 24 | Person _person 25 | JOIN 26 | _person.loginTokens _loginToken 27 | JOIN FETCH 28 | _person.loginTokens 29 | WHERE 30 | _loginToken.tokenHash = :tokenHash AND 31 | _loginToken.type = :tokenType AND 32 | _loginToken.expiration > CURRENT_TIMESTAMP 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/application-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Base settings for the application that are common for all stages. 6 | 7 | 8 | 9 | true 10 | 11 | 12 | Kickoff Example <support@kickoff.example.org> 13 | support@kickoff.example.org 14 | admin@kickoff.example.org 15 | exceptions@kickoff.example.org 16 | 17 | 18 | 19 | ??? 20 | ??? 21 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/datasource-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Base settings for the main data source that are common for all stages. 6 | Note that the initial settings can also be specified in /WEB-INF/web.xml. 7 | 8 | 9 | org.h2.jdbcx.JdbcDataSource 10 | 17 | jdbc:h2:mem:kickoff;DB_CLOSE_DELAY=-1;MODE=LEGACY 18 | sa 19 | sa 20 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/dev/application-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for the application that are specific to the DEV stage. 6 | Note that these are merged with conf/application-settings.xml. 7 | Settings specified here will override those. 8 | 9 | 10 | 11 | dev 12 | http 13 | dev 14 | kickoff.example.org 15 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/dev/datasource-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for the main data source that are specific to the DEV stage. 6 | Note that these are merged with conf/datasource-settings.xml. 7 | Settings specified here will override those. 8 | 9 | 10 | devExamplePassword 11 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/emails.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Emails content 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | *|CTA1_URL|* 30 | ]]> 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 48 |

  • User email: {user.email}
  • 49 |
  • User name: {user.fullName}
  • 50 |
  • Referral: {referral}
  • 51 | 52 | ]]> 53 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 | 70 | 71 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/live/application-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for the application that are specific to the LIVE stage. 6 | Note that these are merged with conf/application-settings.xml. 7 | Settings specified here will override those. 8 | 9 | 10 | 11 | live 12 | false 13 | https 14 | kickoff.example.org 15 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/live/datasource-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for the main data source that are specific to the LIVE stage. 6 | Note that these are merged with conf/datasource-settings.xml. 7 | Settings specified here will override those. 8 | 9 | 10 | liveExamplePassword 11 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/conf/local-dev/application-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for the application that are specific to the LOCAL DEV stage. 6 | Note that these are merged with conf/application-settings.xml. 7 | Settings specified here will override those. 8 | 9 | 10 | 11 | local-dev 12 | http 13 | localhost 14 | 8080 15 | 16 | 17 | true 18 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/omni-settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings for OmniSettings itself. 6 | 7 | 8 | kickoff.stage 9 | 10 | 11 | local-dev 12 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/persistence.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | java:app/kickoff/DataSource 10 | 11 | META-INF/LoginToken.xml 12 | META-INF/Person.xml 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/org.omnifaces.persistence.datasource.PropertiesFileLoader: -------------------------------------------------------------------------------- 1 | org.example.kickoff.config.DataSourceStagedPropertiesFileLoader -------------------------------------------------------------------------------- /src/main/resources/ValidationMessages.properties: -------------------------------------------------------------------------------- 1 | # Standard constraints (for JSF converter/validator messages, see ApplicationBundle.properties) ----------------------- 2 | 3 | jakarta.validation.constraints.NotNull.message = Please fill out this field. 4 | 5 | 6 | # Custom constraints (for JSF converter/validator messages, see ApplicationBundle.properties) ------------------------- 7 | 8 | invalid.email = Please enter a valid email address. 9 | invalid.password = The password must be at least 8 characters long and have at least 1 non-alphabetic character. 10 | 11 | -------------------------------------------------------------------------------- /src/main/resources/ValidationMessages_de.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javaeekickoff/java-ee-kickoff-app/fe5c263814b4cb6e516d706fa144ad4128eaec14/src/main/resources/ValidationMessages_de.properties -------------------------------------------------------------------------------- /src/main/resources/ValidationMessages_nl.properties: -------------------------------------------------------------------------------- 1 | # Standard constraints (for JSF converter/validator messages, see ApplicationBundle.properties) ----------------------- 2 | 3 | jakarta.validation.constraints.NotNull.message = Vul dit in. 4 | 5 | 6 | # Custom constraints (for JSF converter/validator messages, see ApplicationBundle.properties) ------------------------- 7 | 8 | invalid.email = Vul een geldig email in. 9 | invalid.password = Wachtwoord moet minimaal 8 karakters lang zijn en minimaal 1 niet-alfabetisch karakter hebben. 10 | -------------------------------------------------------------------------------- /src/main/resources/org/example/kickoff/i18n/ApplicationBundle_de.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javaeekickoff/java-ee-kickoff-app/fe5c263814b4cb6e516d706fa144ad4128eaec14/src/main/resources/org/example/kickoff/i18n/ApplicationBundle_de.properties -------------------------------------------------------------------------------- /src/main/resources/org/example/kickoff/i18n/ApplicationBundle_en.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javaeekickoff/java-ee-kickoff-app/fe5c263814b4cb6e516d706fa144ad4128eaec14/src/main/resources/org/example/kickoff/i18n/ApplicationBundle_en.properties -------------------------------------------------------------------------------- /src/main/resources/org/example/kickoff/i18n/ApplicationBundle_nl.properties: -------------------------------------------------------------------------------- 1 | # this ---------------------------------------------------------------------------------------------------------------- 2 | 3 | this.name = Kickoff Voorbeeld 4 | this.language.name = Nederlands 5 | this.language.code = nl 6 | 7 | 8 | # general ------------------------------------------------------------------------------------------------------------- 9 | 10 | general.actions = Acties 11 | general.cancel = Annuleren 12 | general.change = Verander 13 | general.company = Bedrijf 14 | general.confirmPassword = Herhaal wachtwoord 15 | general.created = Aangemaakt 16 | general.currentPassword = Huidig wachtwoord 17 | general.edit = Wijzigen 18 | general.email = Email 19 | general.emailVerified = Geverifieerd? 20 | general.forgotPassword = Wachtwoord vergeten? 21 | general.firstName = Voornaam 22 | general.fullName = Volledige naam 23 | general.groups = Groepen 24 | general.home = Hoofdpagina 25 | general.id = ID 26 | general.lastName = Achternaam 27 | general.legal = Rechtelijk 28 | general.login = Inloggen 29 | general.logout = Uitloggen 30 | general.newPassword = Nieuw wachtwoord 31 | general.password = Wachtwoord 32 | general.rememberMe = Onthoud mij op deze computer 33 | general.remove = Verwijderen 34 | general.reset = Herstellen 35 | general.save = Opslaan 36 | general.signup = Inschrijven 37 | general.social = Social 38 | general.unloadmessage = Er is niet-opgeslagen data. Wil je de pagina zeker verlaten? 39 | 40 | 41 | # enums --------------------------------------------------------------------------------------------------------------- 42 | 43 | Group.ADMINISTRATORS = Beheerders 44 | Group.USERS = Gebruikers 45 | 46 | 47 | # general pages ------------------------------------------------------------------------------------------------------- 48 | 49 | home.title = Welkom 50 | about.title = Wij 51 | help.title = Help 52 | contact.title = Contact 53 | terms_of_service.title = Servicevoorwaarden 54 | privacy_policy.title = Privacybeleid 55 | cookie_policy.title = Cookiebeleid 56 | 57 | 58 | # auth pages ---------------------------------------------------------------------------------------------------------- 59 | 60 | login.title = Inloggen 61 | login.paragraph.signup = Nog geen gebruiker? {0}! 62 | 63 | signup.title = Inschrijven 64 | signup.paragraph.login = Al een gebruiker? {0}! 65 | 66 | reset_password.title = Wachtwoord herstellen 67 | 68 | auth.message.error.failure = Authenticatie niet gelukt. Wellicht ben je je wachtwoord vergeten, of wil je je inschrijven? 69 | auth.message.warn.logged_out = Je bent uitgelogd. 70 | auth.message.warn.already_logged_in = Je bent al ingelogd! 71 | 72 | 73 | # user pages ---------------------------------------------------------------------------------------------------------- 74 | 75 | user_profile.title = Jouw profiel 76 | user_profile.header.account = Account 77 | user_profile.header.change_password = Verander wachtwoord 78 | user_profile.message.info.account_updated = Jouw account is bijgewerkt! 79 | user_profile.message.info.password_changed = Jouw wachtwoord is veranderd! 80 | 81 | admin_users.title = Gebruikers 82 | admin_users_edit.title = Wijzig gebruiker 83 | 84 | 85 | # JSF validators (for Bean Validation messages, see ValidationMessages.properties) ------------------------------------ 86 | 87 | jakarta.faces.component.UIInput.REQUIRED = Vul dit in. 88 | 89 | 90 | # Custom converters/validators (for Bean Validation messages, see ValidationMessages.properties) ---------------------- 91 | 92 | localDateConverter = Dat leek niet op een geldige datum. Probeer het nogmaals. 93 | emailVerifiedValidator = Jouw email is nog niet geverifieerd. 94 | duplicateEmailValidator = Dit email is al in gebruik. Wellicht wil je je inloggen? 95 | confirmPasswordValidator = Wachtwoorden waren niet hetzelfde. Probeer het nogmaals. 96 | 97 | 98 | # Custom tags --------------------------------------------------------------------------------------------------------- 99 | 100 | tags.input.checkbox.requiredMessage = Vink dit aan. 101 | tags.table.search.placeholder = Zoek\u2026 102 | tags.table.currentPageReportTemplate = Toont {startRecord} - {endRecord} van {totalRecords} 103 | tags.table.paginatorTemplate = {RowsPerPageDropdown} {CurrentPageReport} {PreviousPageLink} {NextPageLink} 104 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/beans.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/errorpages/400.xhtml: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/errorpages/404.xhtml: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/errorpages/500.xhtml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 |
    13 |

    Detail (only shown during development mode)

    14 |
      15 |
    • Exception: #{requestScope['jakarta.servlet.error.message']}
    • 16 |
    • Request URI: #{fromURL}
    • 17 |
    • Ajax request: #{facesContext.partialViewContext.ajaxRequest ? 'Yes' : 'No'}
    • 18 |
    • User agent: #{header['user-agent']}
    • 19 |
    20 |
    #{of:printStackTrace(requestScope['jakarta.servlet.error.exception'])}
    21 |
    22 |
    23 |
    24 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/errorpages/expired.xhtml: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/faces-config.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | en 11 | nl 12 | de 13 | 14 | org.example.kickoff.i18n.ApplicationBundle 15 | 16 | org.example.kickoff.i18n.ApplicationBundle 17 | i18n 18 | 19 | 20 | org.example.kickoff.view.resourcehandler.ResourceHandlerImplPatch 21 | org.omnifaces.resourcehandler.CombinedResourceHandler 22 | org.example.kickoff.view.viewhandler.LocaleAwareViewHandler 23 | 26 | 27 | 28 | 29 | org.omnifaces.exceptionhandler.FullAjaxExceptionHandlerFactory 30 | 31 | 32 | 33 | org.example.kickoff.view.phaselistener.FacesRequestLoggerPatch 34 | 35 | 36 | 37 | 38 | Disable PrimeFaces HeadRenderer as per http://stackoverflow.com/q/32352869/157882 39 | jakarta.faces.Output 40 | jakarta.faces.Head 41 | com.sun.faces.renderkit.html_basic.HeadRenderer 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/includes/layout/nav-footer.xhtml: -------------------------------------------------------------------------------- 1 | 6 |
    7 |

    #{i18n['general.company']}

    8 |
      9 |
    • 10 |
    • 11 |
    • 12 |
    13 |
    14 | 15 |
    16 |

    #{i18n['general.legal']}

    17 |
      18 |
    • 19 |
    • 20 |
    • 21 |
    22 |
    23 | 24 |
    25 |

    #{i18n['general.social']}

    26 | 31 |
    32 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/includes/layout/nav-header.xhtml: -------------------------------------------------------------------------------- 1 | 6 |
      7 |
    • 8 |
    • 9 |
    • 10 |
    11 | 16 |
    17 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/includes/layout/nav-user.xhtml: -------------------------------------------------------------------------------- 1 | 9 |
      10 | 35 | 49 |
    50 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/includes/layout/resources-body.xhtml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/includes/layout/resources-head.xhtml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PrimeFaces.settings.locale="en"; 20 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/button.xhtml: -------------------------------------------------------------------------------- 1 | 4 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/buttons.xhtml: -------------------------------------------------------------------------------- 1 | 6 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/column.xhtml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | #{value ? 'Y' : 'N'} 17 | #{of:formatNumberDefault(value)} 18 | #{of:formatCurrency(value, '$')} 19 | #{_item}
    20 | 21 | 22 | 23 | 24 | #{value} 25 |
    26 |
    27 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/dev.xhtml: -------------------------------------------------------------------------------- 1 | 4 | 9 |
    10 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/form.xhtml: -------------------------------------------------------------------------------- 1 | 4 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/input.xhtml: -------------------------------------------------------------------------------- 1 | 14 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 94 | 95 | 96 | 97 | 98 | 103 | 104 | 105 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/link.xhtml: -------------------------------------------------------------------------------- 1 | 4 | 10 | 11 | 12 | 13 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/tags/table.xhtml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/errorpage.xhtml: -------------------------------------------------------------------------------- 1 | 12 | 21 | 22 | 23 |
    24 |

    25 | 26 | 27 | 28 |

    29 |

    30 | 31 | 32 | 33 |

    34 |
    35 | 36 | 37 | 38 | 39 | // Scroll back to left top, for the case the page was scrolled halfway or so. 40 | scrollTo(0, 0); 41 | 42 | // Remove any PrimeFaces overlays, in case the exception was thrown while opening a dialog. 43 | // They're opened during oncomplete, the timeout should therefore run after that. 44 | setTimeout(function() { 45 | $(".ui-widget-overlay").remove(); 46 | }, 300); 47 | 48 |
    49 |
    50 |
    -------------------------------------------------------------------------------- /src/main/webapp/WEB-INF/templates/layout.xhtml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <ui:insert name="title">#{page.home ? '' : i18n[page.name += '.title']}</ui:insert>#{page.home ? '' : ' | '}#{i18n['this.name']} 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |