Implement user creation, started project viewing

This commit is contained in:
neviyn 2021-03-30 18:32:24 +01:00
parent 1925220823
commit da45f0c633
15 changed files with 514 additions and 63 deletions

View File

@ -18,18 +18,10 @@
<kotlin.version>1.4.31</kotlin.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>

View File

@ -1,11 +1,11 @@
package uk.co.neviyn.projectplanner
import com.fasterxml.jackson.annotation.JsonBackReference
import com.fasterxml.jackson.annotation.JsonManagedReference
import com.fasterxml.jackson.annotation.JsonIgnore
import java.time.LocalDateTime
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.GeneratedValue
import javax.persistence.GenerationType
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.JoinTable
@ -14,42 +14,47 @@ import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class User(
open class User(
var username: String = "INVALID",
var email: String = "INVALID",
var password: String = "INVALID",
@ManyToMany(cascade = [CascadeType.ALL])
@JsonManagedReference
@JsonIgnore
@JoinTable(
name = "Team",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "project_id", referencedColumnName = "id")]
)
var projects: Set<Project> = mutableSetOf(),
@Id @GeneratedValue var id: Long? = null
)
var projects: MutableSet<Project> = mutableSetOf(),
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
) {}
@Entity
class Project(
var title: String = "INVALID",
@ManyToMany(mappedBy = "projects")
@JsonBackReference
var members: Set<User> = mutableSetOf(),
@JsonIgnore
var members: MutableSet<User> = mutableSetOf(),
@OneToMany(mappedBy = "project")
var events: Set<Event> = mutableSetOf(),
@Id @GeneratedValue var id: Long? = null
@JsonIgnore
var events: MutableSet<Event> = mutableSetOf(),
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
)
@Entity
class Event(
var title: String = "INVALID",
var description: String = "INVALID",
var start: LocalDateTime = LocalDateTime.MIN,
var end: LocalDateTime = LocalDateTime.MIN,
@ManyToOne
@JoinColumn(name = "project_id")
@JsonIgnore
var project: Project? = null,
@ManyToMany(mappedBy = "events")
var tags: Set<Tag> = mutableSetOf(),
@Id @GeneratedValue var id: Long? = null
@JsonIgnore
var tags: MutableSet<Tag> = mutableSetOf(),
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
)
@Entity
@ -59,12 +64,14 @@ class Comment(
var user: User? = null,
@ManyToOne
@JoinColumn(name = "event_id")
@JsonIgnore
var event: Event? = null,
@ManyToMany(mappedBy = "comments")
var tags: Set<Tag> = mutableSetOf(),
@JsonIgnore
var tags: MutableSet<Tag> = mutableSetOf(),
var created: LocalDateTime = LocalDateTime.MIN,
var comment: String = "INVALID",
@Id @GeneratedValue var id: Long? = null
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
)
@Entity
@ -76,13 +83,15 @@ class Tag(
joinColumns = [JoinColumn(name = "tag_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "comment_id", referencedColumnName = "id")]
)
var comments: Set<Comment> = mutableSetOf(),
@JsonIgnore
var comments: MutableSet<Comment> = mutableSetOf(),
@ManyToMany
@JoinTable(
name = "event_tags",
joinColumns = [JoinColumn(name = "tag_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "event_id", referencedColumnName = "id")]
)
var events: Set<Event> = mutableSetOf(),
@Id @GeneratedValue var id: Long? = null
@JsonIgnore
var events: MutableSet<Event> = mutableSetOf(),
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null
)

View File

@ -6,14 +6,37 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import javax.persistence.EntityManager
import javax.transaction.Transactional
@Controller
class HtmlController @Autowired constructor(val userRepository: UserRepository, val projectRepository: ProjectRepository){
class HtmlController @Autowired constructor(val userRepository: UserRepository, val projectRepository: ProjectRepository, val entityManager: EntityManager){
@GetMapping("/")
fun landingPage(@AuthenticationPrincipal userDetails: CustomUserDetails?) : String {
return if(userDetails == null) "landing"
else "redirect:/projects"
}
@GetMapping("/register")
fun register(model: Model) : String {
val user = User(username = "", email = "", password = "")
model.addAttribute("user_details", user)
return "register"
}
@PostMapping("/register")
fun register(@ModelAttribute newUser: User) : String {
newUser.password = passwordEncoder().encode(newUser.password)
userRepository.save(newUser)
return "login"
}
@GetMapping("/login")
fun login(model: Model, error: String?, logout: String?): String? {
@ -22,24 +45,96 @@ class HtmlController @Autowired constructor(val userRepository: UserRepository,
return "login"
}
@GetMapping("/user/{id}")
@ResponseBody
fun getUser(@PathVariable id: Long) : User {
return userRepository.findById(id).get()
@GetMapping("/profile")
fun getLoggedInUser(@AuthenticationPrincipal userDetails: CustomUserDetails, model: Model) : String {
val user = DisplayUser(userDetails.user.id!!, userDetails.user.username, userDetails.user.email, "", "")
model.addAttribute("userData", user)
return "profile"
}
@PreAuthorize("hasPermission(#projectID, 'Long', '')")
@GetMapping("/project/{projectID}")
@ResponseBody
fun getProject(@PathVariable projectID: Long) : Project{
return projectRepository.findById(projectID).get()
@PostMapping("/profile")
@Transactional
fun updateLoggedInUser(@ModelAttribute userData: DisplayUser, @AuthenticationPrincipal userDetails: CustomUserDetails, model: Model) : String {
if(userData.id == userDetails.user.id!! && passwordEncoder().matches(userData.oldPassword, userDetails.password)) {
val user = userDetails.user
user.email = userData.email
if(userData.password.isNotEmpty()) user.password = passwordEncoder().encode(userData.password)
userRepository.save(user)
model.addAttribute("message", "Your profile has been updated")
}
else{
model.addAttribute("error", "Incorrect existing password")
}
model.addAttribute("userData", DisplayUser(userData.id, userData.username, userData.email, userData.password, ""))
return "profile"
}
@GetMapping("/me")
@ResponseBody
fun getLoggedInUser(@AuthenticationPrincipal userDetails: CustomUserDetails) : Long {
return userDetails.user.id!!
@PostMapping("/newproject")
fun createNewProject(@RequestBody newProject: NewProject){
val project = Project(title = newProject.name)
projectRepository.save(project)
}
@GetMapping("/projects")
@Transactional
fun listUserProjects(model: Model, @AuthenticationPrincipal userDetails: CustomUserDetails) : String {
val user = entityManager.merge(userDetails.user) // Reattach User entity
model.addAttribute("projects", user.projects)
return "projectlist"
}
}
@Controller
@RequestMapping("/project/{id}")
class ProjectController @Autowired constructor(val projectRepository: ProjectRepository, val userRepository: UserRepository, val eventRepository: EventRepository) {
@GetMapping("")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun getProject(@PathVariable id: Long, model: Model) : String {
val project = projectRepository.findById(id).get()
model.addAttribute("project", project)
return "project"
}
@GetMapping("/events")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun getProjectEvents(@PathVariable id: Long) : Set<Event> {
return projectRepository.findById(id).get().events
}
@GetMapping("/adduser")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun addUserToProjectForm(@PathVariable id: Long, model: Model) : String{
val project = projectRepository.findById(id).get()
val users = userRepository.findByIdNotIn(project.members.map { it.id!! }).map { SimpleUser(it.id!!, it.username) }
model.addAttribute("available_users", users)
return "addprojectuser"
}
@PostMapping("/adduser")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun addUserToProject(@PathVariable id: Long, @RequestBody u: UserID) {
val user = userRepository.findById(u.id).get()
val project = projectRepository.findById(id).get()
project.members.add(user)
projectRepository.save(project)
}
@PostMapping("/removeuser")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun removeUserFromProject(@PathVariable id: Long, @RequestBody u: UserID) {
val user = userRepository.findById(u.id).get()
val project = projectRepository.findById(id).get()
project.members.remove(user)
projectRepository.save(project)
}
@PostMapping("/addevent")
@PreAuthorize("hasPermission(#id, 'Long', '')")
fun addEventToProject(@PathVariable id: Long, @RequestBody e: NewEvent) {
val project = projectRepository.findById(id).get()
val event = Event(title = e.title, description = e.description, start = e.start, end = e.end, project = project)
eventRepository.save(event)
}
}

View File

@ -4,6 +4,8 @@ import org.springframework.data.repository.CrudRepository
interface UserRepository : CrudRepository<User, Long>{
fun findByUsername(username: String): User?
fun findByUsernameIsLike(partialUsername: String) : List<User>
fun findByIdNotIn(ids: List<Long>) : List<User>
}
interface ProjectRepository : CrudRepository<Project, Long>{}

View File

@ -0,0 +1,13 @@
package uk.co.neviyn.projectplanner
import java.time.LocalDateTime
data class UserID(val id: Long)
data class SimpleUser(val id: Long, val username: String)
data class DisplayUser(val id: Long, val username: String, val email: String, val password: String, val oldPassword: String)
data class NewProject(val name: String)
data class NewEvent(val title: String, val description: String, val start: LocalDateTime, val end: LocalDateTime)

View File

@ -25,10 +25,10 @@ import java.io.Serializable
@Configuration
class SecurityConfig @Autowired constructor(val userDetailsService: UserDetailsServiceImpl) : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http.authorizeRequests().antMatchers("/").permitAll()
http.authorizeRequests().antMatchers("/", "/*.jpg", "/*.svg", "/register").permitAll()
.anyRequest().authenticated().and()
.formLogin().loginPage("/login").permitAll().and()
.logout().permitAll().and()
.logout().logoutSuccessUrl("/").permitAll().and()
.httpBasic()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title>Add user to project | Project Planner</title>
<script src="//cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js"></script>
<script th:inline="javascript">
const userList = [[${available_users}]];
const options = {
valueNames: ['id', 'username'],
// Since there are no elements in the list, this will be used as template.
item: '<li><h3 class="username"></h3></li>'
};
var list = new List('users', options, userList)
</script>
</head>
<body>
<div id="users">
<label>Search
<input class="search" placeholder="Search" />
</label>
<ul class="list"></ul>
</div>
</body>
</html>

View File

@ -1,15 +1,40 @@
<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="baseHeader(title)">
<meta charset="UTF-8">
<title th:replace="${title}">Base Title</title>
<!-- Common styles and scripts -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous">
<link rel="shortcut icon" th:href="@{/images/favicon.ico}">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.0/font/bootstrap-icons.css">
</head>
<body>
<div th:fragment="navbar">
<form id="logoutForm" method="POST" th:action="@{/logout}">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-calendar3 me-1"></i>Project Planner
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-link" aria-current="page" href="/projects">Projects</a>
<a class="nav-link" href="/profile">Profile</a>
</div>
</div>
<div class="navbar-nav navbar-right">
<a class="nav-link" onclick="document.forms['logoutForm'].submit()" style="cursor: pointer"><i class="bi bi-box-arrow-right me-1"></i>Logout</a>
</div>
</div>
</nav>
</div>
</body>
</html>

View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title>Project Planner</title>
</head>
<body>
<div class="container-fluid">
<div class="row justify-content-center mt-3">
<div class="col text-center">
<img th:src="@{/landing_1.jpg}" class="w-25 rounded-3" alt="landing page" src=""/>
</div>
</div>
<div class="row">
<div class="col">
<h1 class="display-1 text-center">Project Planner</h1>
</div>
</div>
<div class="row">
<div class="col">
<p class="lead text-center">
Manage your project deadlines.
</p>
</div>
</div>
<div class="row justify-content-center">
<div class="col text-center">
<a href="/login" class="btn btn-primary btn-lg" role="button">Login</a>
<a href="/register" class="btn btn-secondary btn-lg" role="button">Register</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -8,24 +8,36 @@
<body>
<div class="container">
<form class="form-sign_in" method="POST" th:action="@{/login}">
<h2 class="form-heading">Log in</h2>
<div class="form-group">
<span th:text="${message}"></span>
<label for="username">Username:</label>
<input id="username" name="username" type="text" class="form-control" placeholder="Username" autofocus/>
<label for="password">Password:</label>
<input id="password" name="password" type="password" class="form-control" placeholder="Password"/>
<span class="has-error" th:text="${error}"></span>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<button class="btn btn-lg btn-primary btn-block" type="submit">Log In</button>
<h4 class="text-center"><a href="/registration">Create an account</a></h4>
<div class="row justify-content-center mt-3">
<div class="col text-center">
<h2 class="display-3">Welcome back</h2>
</div>
</form>
</div>
<div class="row justify-content-center mt-3">
<div class="col-8 text-center">
<form class="form-sign_in" method="POST" th:action="@{/login}">
<div class="form-group mt-3">
<span th:text="${message}"></span>
<div class="form-floating mb-3">
<input type="text" class="form-control form-control-lg" id="username" name="username" autofocus>
<label for="username">Username</label>
</div>
<div class="form-floating mb-3">
<input type="password" class="form-control form-control-lg" id="password" name="password">
<label for="password">Password</label>
</div>
<div class="mb-3">
<span class="has-error text-danger" th:text="${error}"></span>
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<div>
<button class="btn btn-lg btn-primary btn-block" type="submit">Log In</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title>Profile | Project Planner</title>
</head>
<body>
<div th:replace="fragments :: navbar"></div>
<div class="container">
<div class="row mt-3 justify-content-center">
<div class="col">
<h2 class="display-2 text-center mb-3">Profile</h2>
</div>
</div>
<form th:action="@{/profile}" th:object="${userData}" method="post">
<div class="row justify-content-center mb-3">
<div class="col-8">
<input type="hidden" th:field="*{id}"/>
<div class="input-group mb-3">
<span class="input-group-text">Username</span>
<input aria-label="Username" class="form-control" readonly th:field="*{username}" type="text">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Email</span>
<input aria-label="Email" class="form-control" th:field="*{email}" type="text">
</div>
<div class="input-group mb-3">
<span class="input-group-text">Password</span>
<input aria-label="Password" class="form-control" th:field="*{password}" type="password">
</div>
<p>Please enter your existing password to update your profile.</p>
<div class="input-group mb-3">
<span class="input-group-text">Existing Password</span>
<input aria-label="Existing Password" type="password" class="form-control form-control-lg"
th:field="*{oldPassword}">
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-2 d-grid">
<button type="submit" class="btn btn-primary btn-lg">Update</button>
</div>
</div>
</form>
<div class="row">
<div class="col text-center">
<span class="text-success" th:text="${message}"></span>
</div>
</div>
<div class="row">
<div class="col text-center">
<span class="has-error text-danger" th:text="${error}"></span>
</div>
</div>
</div>
</body>

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title th:text="${project.title} + ' | Project Planner'">Project Planner</title>
</head>
<head>
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.6.0/main.min.css" rel="stylesheet" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.6.0/main.min.js" crossorigin="anonymous"></script>
<script>
window.onload = function () {
let calendarEl = document.getElementById('calendar');
// noinspection JSUnusedGlobalSymbols
let calendar = new FullCalendar.Calendar(calendarEl, {
themeSystem: 'bootstrap',
initialView: 'dayGridMonth',
bootstrapFontAwesome: false,
fixedWeekCount: false,
editable: true,
nowIndicator: true,
aspectRatio: 2.2,
eventAdd(addInfo) {
console.log(addInfo.event)
}
});
calendar.render();
};
</script>
<title></title>
</head>
<body>
<div th:replace="fragments :: navbar"></div>
<div class="container-fluid">
<div class="row mt-3 justify-content-center">
<div class="col">
<h2 class="display-2 text-center mb-3" th:text="${project.title}">Project Name</h2>
</div>
</div>
<div class="row justify-content-center">
<div class="col-2">
<div class="list-group mb-3">
<button class="list-group-item active" data-bs-toggle="modal" data-bs-target="#newEventModal">Add Event...</button>
</div>
<div class="list-group">
<div class="list-group-item active">Members:</div>
<div th:each="member : ${project.members}">
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<p th:text="${member.username}">username</p>
<a th:href="@{/{pid}/removeuser/{uid}(pid=${id},uid=${member.id})}">
<i class="bi bi-x-circle text-danger"></i>
</a>
</div>
</div>
</div>
<a th:href="@{/{id}/adduser(id=${id})}" class="list-group-item list-group-item-action list-group-item-secondary">Add member</a>
</div>
</div>
<div class="col-10">
<div id="calendar" class="d-flex flex-fill"></div>
</div>
</div>
</div>
<div id="newEventModal" class="modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Event</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="titleInput">
<label for="titleInput">Event Title</label>
</div>
<div class="form-floating mb-3">
<textarea class="form-control h-100" id="descriptionInput" rows="3"></textarea>
<label for="descriptionInput">Event Description</label>
</div>
<div class="form-floating mb-3">
<input type="datetime-local" class="form-control" id="startTimeInput">
<label for="startTimeInput">Start Time</label>
</div>
<div class="form-floating mb-3">
<input type="datetime-local" class="form-control" id="endTimeInput">
<label for="endTimeInput">End Time</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Add Event</button>
</div>
</div>
</div>
</div>
</body>

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title>Projects | Project Planner</title>
</head>
<body>
<form id="logoutForm" method="POST" th:action="@{/logout}">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>
<div th:replace="fragments :: navbar"></div>
<div class="container">
<div class="row mt-3">
<div class="col">
<h2 class="display-2 text-center mb-3">My Projects</h2>
</div>
</div>
<div class="row justify-content-center">
<div class="col-xl-6 col-md-8 col-sm-12">
<div class="list-group" th:each="project : ${projects}">
<!--suppress ThymeleafVariablesResolveInspection -->
<a class="list-group-item list-group-item-action" th:text="${project.title}" th:href="@{~/project/{id}(id=${project.id})}">Title
</a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments :: baseHeader(~{::title})">
<title>Register | Project Planner</title>
</head>
<body>
<div class="container-fluid">
<div class="row mt-3">
<div class="col">
<h2 class="display-2 text-center">Create Account</h2>
</div>
</div>
<div class="row justify-content-center mt-3">
<div class="col-6 text-center">
<form th:action="@{/register}" th:object="${user_details}" method="post" class="needs-validation" novalidate>
<div class="mb-3">
<label for="emailInput" class="form-label">Email address</label>
<input type="email" class="form-control form-control-lg" id="emailInput" th:field="*{email}" required/>
<div class="invalid-feedback">
Please enter an email address.
</div>
</div>
<div class="mb-3">
<label for="usernameInput" class="form-label">Username</label>
<input type="text" class="form-control form-control-lg" id="usernameInput" th:field="*{username}" required/>
<div class="invalid-feedback">
Please enter a username.
</div>
</div>
<div class="mb-3">
<label for="passwordInput" class="form-label">Password</label>
<input type="password" class="form-control form-control-lg" id="passwordInput" th:field="*{password}" required/>
<div class="invalid-feedback">
Please enter a password.
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg">Submit</button>
</form>
</div>
</div>
</div>
<script>
(function () {
'use strict'
// Fetch all the forms we want to apply custom Bootstrap validation styles to
var forms = document.querySelectorAll('.needs-validation')
// Loop over them and prevent submission
Array.prototype.slice.call(forms)
.forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
})()
</script>
</body>
</html>