Update html layout

- add messages fragment
  - extract error and success message list from edit.html
  - extract error message list from create.html
  - add messages.html

- update edit account
  - update AccountController
    - update postEditAccount for validation
    - update getEditAccount for roleGroups
  - update validation for AccountForm for edit
  - add EditGroup
  - update Account.Response.toAccountForm()
  - update edit.html

- update create account
  - update AccountController
    - update postCreateAccount for validation
    - update getCreateAccount for role group
  - add validation to AccountForm
  - add PasswordMatchValidator
  - add annotation PasswordMatch
  - add CreateGroup
  - add temporary getRoles() in AccountRegistryService
  - update AccountForm.toAccountRequest() in Mapping
  - change management.css
    - add ::selection
    - add Selected Option Styling
  - add passwordMatchCheck to management.js
  - update create.html

- update user
  - add makeLocalTime in management.js
  - update users.html
  - update Pagination
    - add size of items
    - rename size to show

- update goodbye.html

- update logout.html

- update login.html

- update welcome.html

- update index.html

- update layout
  - update management.css
  - update management.js
  - update layout.html
This commit is contained in:
2025-03-01 16:04:56 +01:00
parent 4c6c7dd9d8
commit 4c4baa95dd
22 changed files with 807 additions and 344 deletions

View File

@@ -1,10 +1,11 @@
package ltd.hlaeja.controller
import java.util.UUID
import ltd.hlaeja.controller.validation.CreateGroup
import ltd.hlaeja.controller.validation.EditGroup
import ltd.hlaeja.dto.Pagination
import ltd.hlaeja.exception.NoChangeException
import ltd.hlaeja.exception.NotFoundException
import ltd.hlaeja.exception.PasswordException
import ltd.hlaeja.exception.UsernameDuplicateException
import ltd.hlaeja.form.AccountForm
import ltd.hlaeja.service.AccountRegistryService
@@ -12,6 +13,8 @@ import ltd.hlaeja.util.toAccountForm
import ltd.hlaeja.util.toAccountRequest
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.BindingResult
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ModelAttribute
import org.springframework.web.bind.annotation.PathVariable
@@ -36,6 +39,7 @@ class AccountController(
): Mono<String> = accountRegistryService.getAccount(account)
.doOnNext {
model.addAttribute("account", account)
model.addAttribute("roleGroups", accountRegistryService.getRoles())
model.addAttribute("accountForm", it.toAccountForm())
}
.then(Mono.just("account/edit"))
@@ -43,65 +47,93 @@ class AccountController(
@PostMapping("/edit-{account}")
fun postEditAccount(
@PathVariable account: UUID,
@ModelAttribute("accountForm") accountForm: AccountForm,
@Validated(EditGroup::class) @ModelAttribute("accountForm") accountForm: AccountForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> = Mono.just(accountForm)
.flatMap {
accountRegistryService.updateAccount(
account,
it.toAccountRequest { password -> if (password.isNullOrEmpty()) null else password },
)
}
.doOnNext {
model.addAttribute("successMessage", "Saved changes!!!")
model.addAttribute("account", account)
model.addAttribute("accountForm", it.toAccountForm())
}
.then(Mono.just("account/edit"))
.onErrorResume { error ->
val errorMessage = when (error) {
is NoChangeException -> Pair("successMessage", "No change to save")
is NotFoundException -> Pair("errorMessage", "User dont exists. how did this happen?")
is UsernameDuplicateException -> Pair("errorMessage", "Username already exists. Please choose another.")
else -> Pair("errorMessage", "An unexpected error occurred. Please try again later.")
): Mono<String> {
val validationErrors = if (bindingResult.hasErrors()) {
bindingResult.allErrors.map { error ->
error.defaultMessage ?: "Unknown validation error"
}
model.addAttribute(errorMessage.first, errorMessage.second)
model.addAttribute("accountForm", accountForm)
model.addAttribute("account", account)
Mono.just("account/edit")
} else {
emptyList()
}
if (bindingResult.hasErrors()) {
model.addAttribute("accountForm", accountForm)
model.addAttribute("validationErrors", validationErrors)
model.addAttribute("roleGroups", accountRegistryService.getRoles())
return Mono.just("account/edit")
}
return Mono.just(accountForm)
.flatMap { accountRegistryService.updateAccount(account, it.toAccountRequest()) }
.doOnNext {
model.addAttribute("successMessage", listOf("Saved changes!!!"))
model.addAttribute("account", account)
model.addAttribute("accountForm", it.toAccountForm())
model.addAttribute("roleGroups", accountRegistryService.getRoles())
}
.then(Mono.just("account/edit"))
.onErrorResume { error ->
val errorMessage = when (error) {
is NoChangeException -> Pair("successMessage", "No change to save.")
is NotFoundException -> Pair("validationErrors", "User dont exists. how did this happen?")
is UsernameDuplicateException -> Pair(
"validationErrors",
"Username already exists. Please choose another.",
)
else -> Pair("validationErrors", "An unexpected error occurred. Please try again later.")
}
model.addAttribute(errorMessage.first, listOf(errorMessage.second))
model.addAttribute("account", account)
model.addAttribute("accountForm", accountForm)
model.addAttribute("roleGroups", accountRegistryService.getRoles())
Mono.just("account/edit")
}
}
@GetMapping("/create")
fun getCreateAccount(
model: Model,
): Mono<String> = Mono.just("account/create")
.doOnNext { model.addAttribute("accountForm", AccountForm("", "")) }
.doOnNext {
model.addAttribute("accountForm", AccountForm("", emptyList()))
model.addAttribute("roleGroups", accountRegistryService.getRoles())
}
@PostMapping("/create")
fun postCreateAccount(
@ModelAttribute("accountForm") accountForm: AccountForm,
@Validated(CreateGroup::class) @ModelAttribute("accountForm") accountForm: AccountForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> = Mono.just(accountForm)
.flatMap {
accountRegistryService.addAccount(
it.toAccountRequest { password ->
when {
password.isNullOrEmpty() -> throw PasswordException("Password requirements failed")
else -> password
}
},
)
}
.map { "redirect:/account" }
.onErrorResume { error ->
val errorMessage = when (error) {
is UsernameDuplicateException -> "Username already exists. Please choose another."
is PasswordException -> error.message
else -> "An unexpected error occurred. Please try again later."
): Mono<String> {
val validationErrors = if (bindingResult.hasErrors()) {
bindingResult.allErrors.map { error ->
error.defaultMessage ?: "Unknown validation error"
}
model.addAttribute("errorMessage", errorMessage)
Mono.just("account/create")
} else {
emptyList()
}
if (bindingResult.hasErrors()) {
model.addAttribute("accountForm", accountForm)
model.addAttribute("validationErrors", validationErrors)
model.addAttribute("roleGroups", accountRegistryService.getRoles())
return Mono.just("account/create")
}
return Mono.just(accountForm)
.flatMap { accountRegistryService.addAccount(it.toAccountRequest()) }
.map { "redirect:/account" }
.onErrorResume { error ->
val errorMessage = when (error) {
is UsernameDuplicateException -> "Username already exists. Please choose another."
else -> "An unexpected error occurred. Please try again later."
}
model.addAttribute("validationErrors", listOf(errorMessage))
model.addAttribute("roleGroups", accountRegistryService.getRoles())
Mono.just("account/create")
}
}
@GetMapping
fun getDefaultAccounts(

View File

@@ -0,0 +1,3 @@
package ltd.hlaeja.controller.validation
interface CreateGroup

View File

@@ -0,0 +1,3 @@
package ltd.hlaeja.controller.validation
interface EditGroup

View File

@@ -0,0 +1,15 @@
package ltd.hlaeja.controller.validation
import jakarta.validation.Constraint
import jakarta.validation.Payload
import kotlin.reflect.KClass
@MustBeDocumented
@Constraint(validatedBy = [PasswordMatchValidator::class])
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class PasswordMatch(
val message: String = "Passwords must match",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
)

View File

@@ -0,0 +1,17 @@
package ltd.hlaeja.controller.validation
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import ltd.hlaeja.form.AccountForm
class PasswordMatchValidator : ConstraintValidator<PasswordMatch, AccountForm> {
override fun isValid(form: AccountForm, context: ConstraintValidatorContext): Boolean {
val password = form.password?.toString()
val passwordConfirm = form.passwordConfirm?.toString()
return if (password.isNullOrEmpty() && passwordConfirm.isNullOrEmpty()) {
true
} else {
password == passwordConfirm
}
}
}

View File

@@ -3,15 +3,16 @@ package ltd.hlaeja.dto
@Suppress("unused")
data class Pagination(
val page: Int,
val size: Int,
val show: Int,
val items: Int,
val defaultSize: Int,
) {
val hasMore: Boolean = size == items
val showSize: Boolean = size != defaultSize
val hasMore: Boolean = show == items
val showSize: Boolean = show != defaultSize
val first: Boolean = page <= 1
val previous: Int = page - 1
val next: Int = page + 1
val start: Int = (page - 1) * size + 1
val end: Int = page * size
val start: Int = previous * show + 1
val end: Int = page * show
val size: Int = previous * show + items
}

View File

@@ -1,9 +1,19 @@
package ltd.hlaeja.form
import jakarta.validation.constraints.NotEmpty
import ltd.hlaeja.controller.validation.CreateGroup
import ltd.hlaeja.controller.validation.EditGroup
import ltd.hlaeja.controller.validation.PasswordMatch
@PasswordMatch(groups = [CreateGroup::class, EditGroup::class])
data class AccountForm(
val username: String,
val role: String,
val enabled: Boolean = false,
val password: CharSequence? = null,
val passwordConfirm: CharSequence? = null,
@field:NotEmpty(message = "Username cannot be empty", groups = [CreateGroup::class, EditGroup::class])
var username: String,
@field:NotEmpty(message = "At least one role must be selected", groups = [CreateGroup::class, EditGroup::class])
var roles: List<String> = emptyList(),
var enabled: Boolean = false,
@field:NotEmpty(message = "Password cannot be empty", groups = [CreateGroup::class])
var password: CharSequence? = null,
@field:NotEmpty(message = "Password confirmation cannot be empty", groups = [CreateGroup::class])
var passwordConfirm: CharSequence? = null,
)

View File

@@ -90,4 +90,11 @@ class AccountRegistryService(
else -> Mono.error(ResponseStatusException(BAD_REQUEST, error.message))
}
}
// TODO implement user gropes and access
fun getRoles(): Map<String, List<String>> = mapOf(
"Admin Group" to listOf("Admin"),
"Operations Group" to listOf("Registry"),
"User Group" to listOf("User"),
)
}

View File

@@ -10,15 +10,19 @@ fun SpringAuthentication.toAuthenticationRequest(): Authentication.Request = Aut
credentials as String,
)
fun AccountForm.toAccountRequest(operation: (CharSequence?) -> CharSequence?): Account.Request = Account.Request(
fun AccountForm.toAccountRequest(): Account.Request = Account.Request(
username = username,
password = operation(password),
password = if (password.isNullOrEmpty()) null else password,
enabled = enabled,
roles = listOf("ROLE_${role.uppercase()}"),
roles = roles.map { "ROLE_${it.uppercase()}" },
)
fun Account.Response.toAccountForm(): AccountForm = AccountForm(
username = username,
enabled = enabled,
role = roles.first().removePrefix("ROLE_").lowercase(),
roles = roles.map {
it.removePrefix("ROLE_")
.lowercase()
.replaceFirstChar { char -> char.uppercase() }
},
)