add device type

add postEditType to TypeController

update form.html and messages.html for saving types

add updateType in DeviceRegistryService

add WebClient deviceRegistryTypesUpdate in DeviceRegisterWebClientCalls.kt

add getEditType to TypeController

update type form.html for edit device type

add Type Response toTypeForm in Mapping.kt

add getType to DeviceRegistryService

add WebClient deviceRegistryType in DeviceRegisterWebClientCalls.kt

update edit link in device type list.html

add getCreateType and postCreateType to TypeController

add device type form.html

add TypeFormadd TypeForm toTypeRequest in Mapping.kt

add TypeForm

add createType to DeviceRegistryService

add deviceRegistryTypesCreate in DeviceRegisterWebClientCalls.kt

add TypeNameDuplicateException

add DeviceRegistryException

extract validationErrors from postCreateAccount in AccountController to util Controller

update AccountController getAccounts and TypeController getTypes with max show value

add redis for spring boot session
  - make RemoteUserDetail Serializable
  - add application properties
  - add dependencies

add TypeController

add list.html for type

extract fragment pagination.html from users.html

add DeviceRegistryService with getTypes

add DeviceRegisterWebClientCalls.kt with deviceRegistryTypes

set up hlaeja device registry

add type to layout menu

add type to admin SecurityConfiguration
This commit is contained in:
2025-03-04 00:01:38 +01:00
parent 4c4baa95dd
commit 1525702f07
24 changed files with 544 additions and 76 deletions

View File

@@ -1,12 +1,14 @@
package ltd.hlaeja
import ltd.hlaeja.property.AccountRegistryProperty
import ltd.hlaeja.property.DeviceRegistryProperty
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
@EnableConfigurationProperties(
AccountRegistryProperty::class,
DeviceRegistryProperty::class,
)
@SpringBootApplication
class Application

View File

@@ -36,6 +36,7 @@ class SecurityConfiguration {
private fun AuthorizeExchangeSpec.adminPaths(): AuthorizeExchangeSpec.Access = pathMatchers(
"/account/**",
"/type/**",
)
private fun AuthorizeExchangeSpec.publicPaths(): AuthorizeExchangeSpec.Access = pathMatchers(

View File

@@ -1,5 +1,7 @@
package ltd.hlaeja.controller
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import java.util.UUID
import ltd.hlaeja.controller.validation.CreateGroup
import ltd.hlaeja.controller.validation.EditGroup
@@ -11,6 +13,7 @@ import ltd.hlaeja.form.AccountForm
import ltd.hlaeja.service.AccountRegistryService
import ltd.hlaeja.util.toAccountForm
import ltd.hlaeja.util.toAccountRequest
import ltd.hlaeja.util.validationErrors
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.validation.BindingResult
@@ -30,6 +33,8 @@ class AccountController(
companion object {
const val DEFAULT_PAGE: Int = 1
const val DEFAULT_SIZE: Int = 25
const val MIN: Long = 1
const val MAX: Long = 100
}
@GetMapping("/edit-{account}")
@@ -50,22 +55,13 @@ class AccountController(
@Validated(EditGroup::class) @ModelAttribute("accountForm") accountForm: AccountForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> {
val validationErrors = if (bindingResult.hasErrors()) {
bindingResult.allErrors.map { error ->
error.defaultMessage ?: "Unknown validation error"
}
} 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)
): Mono<String> = if (bindingResult.hasErrors()) {
model.addAttribute("accountForm", accountForm)
model.addAttribute("validationErrors", validationErrors(bindingResult))
model.addAttribute("roleGroups", accountRegistryService.getRoles())
Mono.just("account/edit")
} else {
Mono.just(accountForm)
.flatMap { accountRegistryService.updateAccount(account, it.toAccountRequest()) }
.doOnNext {
model.addAttribute("successMessage", listOf("Saved changes!!!"))
@@ -82,6 +78,7 @@ class AccountController(
"validationErrors",
"Username already exists. Please choose another.",
)
else -> Pair("validationErrors", "An unexpected error occurred. Please try again later.")
}
@@ -107,21 +104,13 @@ class AccountController(
@Validated(CreateGroup::class) @ModelAttribute("accountForm") accountForm: AccountForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> {
val validationErrors = if (bindingResult.hasErrors()) {
bindingResult.allErrors.map { error ->
error.defaultMessage ?: "Unknown validation error"
}
} 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)
): Mono<String> = if (bindingResult.hasErrors()) {
model.addAttribute("accountForm", accountForm)
model.addAttribute("roleGroups", accountRegistryService.getRoles())
model.addAttribute("validationErrors", validationErrors(bindingResult))
Mono.just("account/create")
} else {
Mono.just(accountForm)
.flatMap { accountRegistryService.addAccount(it.toAccountRequest()) }
.map { "redirect:/account" }
.onErrorResume { error ->
@@ -135,33 +124,20 @@ class AccountController(
}
}
@GetMapping
fun getDefaultAccounts(
@GetMapping(
"",
"/page-{page}",
"/page-{page}/show-{show}",
)
fun getAccounts(
@PathVariable(required = false) @Min(MIN) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(MIN) @Max(MAX) show: Int = DEFAULT_SIZE,
model: Model,
): Mono<String> = getAccounts(DEFAULT_PAGE, DEFAULT_SIZE, model)
@GetMapping("/page-{page}")
fun getAccountsPage(
@PathVariable page: Int,
model: Model,
): Mono<String> = getAccounts(page, DEFAULT_SIZE, model)
@GetMapping("/page-{page}/show-{size}")
fun getAccountsPageSize(
@PathVariable page: Int,
@PathVariable size: Int,
model: Model,
): Mono<String> = getAccounts(page, size, model)
private fun getAccounts(
page: Int,
size: Int,
model: Model,
) = accountRegistryService.getAccounts(page, size)
): Mono<String> = accountRegistryService.getAccounts(page, show)
.collectList()
.doOnNext { items ->
model.addAttribute("items", items)
model.addAttribute("pagination", Pagination(page, size, items.size, DEFAULT_SIZE))
model.addAttribute("pagination", Pagination(page, show, items.size, DEFAULT_SIZE))
}
.then(Mono.just("account/users"))
}

View File

@@ -0,0 +1,142 @@
package ltd.hlaeja.controller
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import java.util.UUID
import ltd.hlaeja.dto.Pagination
import ltd.hlaeja.exception.NoChangeException
import ltd.hlaeja.exception.NotFoundException
import ltd.hlaeja.exception.TypeNameDuplicateException
import ltd.hlaeja.form.TypeForm
import ltd.hlaeja.service.DeviceRegistryService
import ltd.hlaeja.util.toTypeForm
import ltd.hlaeja.util.toTypeRequest
import ltd.hlaeja.util.validationErrors
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
import org.springframework.web.bind.annotation.PostMapping
import reactor.core.publisher.Mono
@Controller
class TypeController(
private val deviceRegistryService: DeviceRegistryService,
) {
companion object {
const val DEFAULT_PAGE: Int = 1
const val DEFAULT_SIZE: Int = 25
const val MIN: Long = 1
const val MAX: Long = 100
}
@GetMapping(
"/type",
"/type/page-{page}",
"/type/page-{page}/show-{show}",
)
fun getTypes(
@PathVariable(required = false) @Min(MIN) page: Int = DEFAULT_PAGE,
@PathVariable(required = false) @Min(MIN) @Max(MAX) show: Int = DEFAULT_SIZE,
model: Model,
) = deviceRegistryService.getTypes(page, show)
.collectList()
.doOnNext { items ->
model.addAttribute("items", items)
model.addAttribute("pagination", Pagination(page, show, items.size, DEFAULT_SIZE))
}
.then(Mono.just("device/type/list"))
@GetMapping("/type/create")
fun getCreateType(
model: Model,
): Mono<String> = Mono.just("device/type/form")
.doOnNext {
model.addAttribute("typeForm", TypeForm())
}
@PostMapping("/type/create")
fun postCreateType(
@Validated @ModelAttribute("typeForm") typeForm: TypeForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> = if (bindingResult.hasErrors()) {
model.addAttribute("typeForm", typeForm)
model.addAttribute("validationErrors", validationErrors(bindingResult))
Mono.just("device/type/form")
} else {
Mono.just(typeForm)
.flatMap { deviceRegistryService.createType(it.toTypeRequest()) }
.map { "redirect:/type" }
.onErrorResume { error ->
val errorMessage = when (error) {
is TypeNameDuplicateException -> "Type name already exists. Please choose another."
else -> "An unexpected error occurred. Please try again later."
}
model.addAttribute("validationErrors", listOf(errorMessage))
Mono.just("device/type/form")
}
}
@GetMapping("/type-{type}")
fun getEditType(
@PathVariable type: UUID,
model: Model,
): Mono<String> = deviceRegistryService.getType(type)
.doOnNext {
model.addAttribute("type", it)
model.addAttribute("typeForm", it.toTypeForm())
}
.then(Mono.just("device/type/form"))
@PostMapping("/type-{type}")
fun postEditType(
@PathVariable type: UUID,
@Validated @ModelAttribute("typeForm") typeForm: TypeForm,
bindingResult: BindingResult,
model: Model,
): Mono<String> = if (bindingResult.hasErrors()) {
deviceRegistryService.getType(type)
.doOnNext {
model.addAttribute("type", it)
model.addAttribute("typeForm", typeForm)
model.addAttribute("validationErrors", validationErrors(bindingResult))
}
.then(
Mono.just("device/type/form"),
)
} else {
Mono.just(typeForm)
.flatMap { deviceRegistryService.updateType(type, it.toTypeRequest()) }
.doOnNext {
model.addAttribute("successMessage", listOf("Saved changes!!!"))
model.addAttribute("type", it)
model.addAttribute("typeForm", it.toTypeForm())
}
.then(Mono.just("device/type/form"))
.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 TypeNameDuplicateException -> Pair(
"validationErrors",
"Type name already exists. Please choose another.",
)
else -> Pair("validationErrors", "An unexpected error occurred. Please try again later.")
}
deviceRegistryService.getType(type)
.doOnNext {
model.addAttribute(errorMessage.first, listOf(errorMessage.second))
model.addAttribute("type", it)
model.addAttribute("typeForm", typeForm)
}
.then(
Mono.just("device/type/form"),
)
}
}
}

View File

@@ -0,0 +1,23 @@
package ltd.hlaeja.exception
@Suppress("unused")
open class DeviceRegistryException : HlaejaException {
constructor() : super()
constructor(message: String) : super(message)
constructor(cause: Throwable) : super(cause)
constructor(
message: String,
cause: Throwable,
) : super(message, cause)
constructor(
message: String,
cause: Throwable,
enableSuppression: Boolean,
writableStackTrace: Boolean,
) : super(message, cause, enableSuppression, writableStackTrace)
}

View File

@@ -0,0 +1,23 @@
package ltd.hlaeja.exception
@Suppress("unused")
open class TypeNameDuplicateException : DeviceRegistryException {
constructor() : super()
constructor(message: String) : super(message)
constructor(cause: Throwable) : super(cause)
constructor(
message: String,
cause: Throwable,
) : super(message, cause)
constructor(
message: String,
cause: Throwable,
enableSuppression: Boolean,
writableStackTrace: Boolean,
) : super(message, cause, enableSuppression, writableStackTrace)
}

View File

@@ -0,0 +1,10 @@
package ltd.hlaeja.form
import jakarta.validation.constraints.Size
data class TypeForm(
@field:Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
val name: String = "",
@field:Size(min = 2, max = 1000, message = "Description must be between 2 and 1000 characters")
val description: String = "",
)

View File

@@ -0,0 +1,8 @@
package ltd.hlaeja.property
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "device-registry")
data class DeviceRegistryProperty(
val url: String,
)

View File

@@ -1,8 +1,14 @@
package ltd.hlaeja.security
import java.io.Serializable
import java.util.UUID
data class RemoteUserDetail(
val id: UUID,
val username: String,
)
) : Serializable {
companion object {
@Suppress("ConstPropertyName")
private const val serialVersionUID = 1L
}
}

View File

@@ -0,0 +1,63 @@
package ltd.hlaeja.service
import java.util.UUID
import ltd.hlaeja.exception.DeviceRegistryException
import ltd.hlaeja.exception.HlaejaException
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import ltd.hlaeja.property.DeviceRegistryProperty
import ltd.hlaeja.util.deviceRegistryType
import ltd.hlaeja.util.deviceRegistryTypes
import ltd.hlaeja.util.deviceRegistryTypesCreate
import ltd.hlaeja.util.deviceRegistryTypesUpdate
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Service
class DeviceRegistryService(
private val webClient: WebClient,
private val property: DeviceRegistryProperty,
) {
fun getTypes(
page: Int,
show: Int,
): Flux<Types.Response> = webClient.deviceRegistryTypes(page, show, property)
fun createType(
request: Type.Request,
): Mono<Type.Response> = webClient.deviceRegistryTypesCreate(request, property)
.onErrorResume { error ->
when (error) {
is DeviceRegistryException -> Mono.error(error)
else -> Mono.error(ResponseStatusException(BAD_REQUEST, error.message))
}
}
fun getType(
type: UUID,
): Mono<Type.Response> = webClient.deviceRegistryType(type, property)
.onErrorResume { error ->
when (error) {
is ResponseStatusException -> Mono.error(error)
else -> Mono.error(ResponseStatusException(BAD_REQUEST, error.message))
}
}
fun updateType(
type: UUID,
request: Type.Request,
): Mono<Type.Response> = webClient.deviceRegistryTypesUpdate(type, request, property)
.onErrorResume(::errorHandler)
private fun errorHandler(
error: Throwable,
): Mono<out Type.Response> = when (error) {
is HlaejaException -> Mono.error(error)
else -> Mono.error(ResponseStatusException(BAD_REQUEST, error.message))
}
}

View File

@@ -0,0 +1,11 @@
package ltd.hlaeja.util
import org.springframework.validation.BindingResult
fun validationErrors(
bindingResult: BindingResult,
): List<String> = if (bindingResult.hasErrors()) {
bindingResult.allErrors.map { error -> error.defaultMessage ?: "Unknown validation error" }
} else {
emptyList()
}

View File

@@ -0,0 +1,61 @@
package ltd.hlaeja.util
import java.util.UUID
import ltd.hlaeja.exception.DeviceRegistryException
import ltd.hlaeja.exception.NoChangeException
import ltd.hlaeja.exception.NotFoundException
import ltd.hlaeja.exception.TypeNameDuplicateException
import ltd.hlaeja.library.deviceRegistry.Type
import ltd.hlaeja.library.deviceRegistry.Types
import ltd.hlaeja.property.DeviceRegistryProperty
import org.springframework.http.HttpStatus.ACCEPTED
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.CONFLICT
import org.springframework.http.HttpStatus.NOT_FOUND
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.server.ResponseStatusException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun WebClient.deviceRegistryTypes(
page: Int,
size: Int,
property: DeviceRegistryProperty,
): Flux<Types.Response> = get()
.uri("${property.url}/types/page-$page/show-$size".also(::logCall))
.retrieve()
.bodyToFlux(Types.Response::class.java)
fun WebClient.deviceRegistryTypesCreate(
request: Type.Request,
property: DeviceRegistryProperty,
): Mono<Type.Response> = post()
.uri("${property.url}/type".also(::logCall))
.bodyValue(request)
.retrieve()
.onStatus(CONFLICT::equals) { throw TypeNameDuplicateException("Remote service returned 409") }
.onStatus(BAD_REQUEST::equals) { throw DeviceRegistryException("Remote service returned 400") }
.bodyToMono(Type.Response::class.java)
fun WebClient.deviceRegistryType(
type: UUID,
property: DeviceRegistryProperty,
): Mono<Type.Response> = get()
.uri("${property.url}/type-$type".also(::logCall))
.retrieve()
.onStatus(NOT_FOUND::equals) { throw ResponseStatusException(NOT_FOUND) }
.bodyToMono(Type.Response::class.java)
fun WebClient.deviceRegistryTypesUpdate(
type: UUID,
request: Type.Request,
property: DeviceRegistryProperty,
): Mono<Type.Response> = put()
.uri("${property.url}/type-$type".also(::logCall))
.bodyValue(request)
.retrieve()
.onStatus(ACCEPTED::equals) { throw NoChangeException("Remote service returned 202") }
.onStatus(BAD_REQUEST::equals) { throw DeviceRegistryException("Remote service returned 400") }
.onStatus(NOT_FOUND::equals) { throw NotFoundException("Remote service returned 404") }
.onStatus(CONFLICT::equals) { throw TypeNameDuplicateException("Remote service returned 409") }
.bodyToMono(Type.Response::class.java)

View File

@@ -1,8 +1,10 @@
package ltd.hlaeja.util
import ltd.hlaeja.form.AccountForm
import ltd.hlaeja.form.TypeForm
import ltd.hlaeja.library.accountRegistry.Account
import ltd.hlaeja.library.accountRegistry.Authentication
import ltd.hlaeja.library.deviceRegistry.Type
import org.springframework.security.core.Authentication as SpringAuthentication
fun SpringAuthentication.toAuthenticationRequest(): Authentication.Request = Authentication.Request(
@@ -26,3 +28,13 @@ fun Account.Response.toAccountForm(): AccountForm = AccountForm(
.replaceFirstChar { char -> char.uppercase() }
},
)
fun TypeForm.toTypeRequest(): Type.Request = Type.Request(
name = name,
description = description,
)
fun Type.Response.toTypeForm(): Type.Request = Type.Request(
name = name,
description = description,
)

View File

@@ -9,6 +9,14 @@ spring:
os:
name: "%APP_BUILD_OS_NAME%"
version: "%APP_BUILD_OS_VERSION%"
session:
timeout: 60m
redis:
namespace: "spring:session:management"
data:
redis:
port: 6379
management:
endpoints:
@@ -42,10 +50,17 @@ spring:
web:
resources:
static-locations: file:src/main/resources/static/
data:
redis:
host: localhost
database: 2
account-registry:
url: http://localhost:9050
device-registry:
url: http://localhost:9010
---
##########################
### Docker environment ###
@@ -54,10 +69,17 @@ spring:
config:
activate:
on-profile: docker
data:
redis:
host: Redis
database: 2
account-registry:
url: http://AccountRegistry:8080
device-registry:
url: http://DeviceRegistry:8080
---
##############################
### Production environment ###

View File

@@ -17,7 +17,7 @@
<span th:text="${pagination.size}"/>
</div>
<div class="mt-[-2px]">
<a th:href="@{/account/create}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors text-sm">$ Create New Account</a>
<a th:href="@{/account/create}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors text-sm">Create New Account</a>
</div>
</div>
<div class="overflow-x-auto">
@@ -39,29 +39,13 @@
</tr>
</table>
</div>
<div class="mt-6 text-sm" th:classappend="${pagination.showSize} ? 'space-y-2' : ''">
<div th:if="${pagination.showSize}" class="mt-6 flex justify-between items-center text-sm">
<span th:if="${pagination.first}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Previous</span>
<a th:unless="${pagination.first}" th:href="@{'/account/page-' + ${pagination.previous} + '/show-' + ${pagination.show}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Previous</a>
<a th:if="${pagination.hasMore}" th:href="@{'/account/page-' + ${pagination.next} + '/show-' + ${pagination.show}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Next</a>
<span th:unless="${pagination.hasMore}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Next</span>
</div>
<div th:unless="${pagination.showSize}" class="mt-6 flex justify-between items-center text-sm">
<span th:if="${pagination.first}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Previous</span>
<a th:unless="${pagination.first}" th:href="@{'/account/page-' + ${pagination.previous}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Previous</a>
<a th:if="${pagination.hasMore}" th:href="@{'/account/page-' + ${pagination.next}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Next</a>
<span th:unless="${pagination.hasMore}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Next</span>
</div>
</div>
<!--/*/<th:block th:replace="~{pagination :: pagination('/account', ${pagination})}"/>/*/-->
</div>
</main>
<!--/*/<th:block th:replace="~{layout.html :: footer}"/>/*/-->
<!--/*/<th:block th:replace="~{layout.html :: script}"/>/*/-->
<script>
// Assuming makeLocalTime is defined elsewhere or needs to be added
document.addEventListener('DOMContentLoaded', () => {
makeLocalTime(document.querySelectorAll('.utcTimestamp'));
});
document.addEventListener('DOMContentLoaded', () => makeLocalTime(document.querySelectorAll('.utcTimestamp')));
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{layout.html :: documentHead ('Hlaeja Management')}"/>
<body class="bg-gray-900 text-green-400 min-h-screen flex flex-col">
<th:block th:replace="~{layout.html :: header}"/>
<main class="container mx-auto p-4 flex-grow flex items-center justify-center">
<div class="w-full max-w-3xl">
<div class="flex justify-between items-end mb-4">
<div>
<h2 class="text-lg sm:text-xl terminal-glow" th:if="${type} == null" th:text="'Create Device Type'"></h2>
<h2 class="text-lg sm:text-xl terminal-glow" th:unless="${type} == null" th:text="'Edit Device Type'"></h2>
</div>
<div th:if="${type} != null" class="text-xs text-green-600">
Created: <span class="utcTimestamp" th:data-timestamp="${type.timestamp}"></span>
</div>
</div>
<hr class="border-green-900 mb-4">
<th:block th:replace="~{messages :: messageDisplay(messageList=${validationErrors}, error=true, styleClass='text-red-600')}"/>
<th:block th:replace="~{messages :: messageDisplay(messageList=${successMessage}, error=false, styleClass='text-green-600')}"/>
<form th:action="${type} == null ? @{/type/create} : @{/type-{id}(id=${type.id})}" th:method="post">
<div class="bg-gray-800 p-6 rounded-lg border border-green-900">
<div class="mb-4">
<label for="username" class="block text-sm mb-2">Name</label>
<input th:field="*{typeForm.name}" id="username" type="text" placeholder="Enter name..." class="w-full bg-gray-900 border border-green-900 rounded px-3 py-2 text-green-400 focus:outline-none focus:border-green-600">
</div>
<div class="mb-4">
<label class="block text-sm mb-2" for="textarea">Description</label>
<textarea id="textarea" th:field="*{typeForm.description}" rows="12" class="w-full bg-gray-900 border border-green-900 rounded px-3 py-2 text-green-400 focus:outline-none focus:border-green-600" placeholder="Enter description..."></textarea>
</div>
</div>
<div class="mt-4 flex justify-end space-x-4">
<a href="/type" class="bg-gray-800 hover:bg-gray-700 text-green-400 px-4 py-2 rounded border border-green-900 transition-colors inline-block" th:text="${type} == null ? 'Cancel' : 'Go Back'"/>
<button type="submit" class="bg-green-900 hover:bg-green-800 text-green-400 px-4 py-2 rounded border border-green-600 transition-colors" th:text="${type} == null ? 'Create' : 'Save'"/>
</div>
</form>
</div>
</main>
<th:block th:replace="~{layout.html :: footer}"/>
<th:block th:replace="~{layout.html :: script}"/>
<script th:if="${type} != null">
document.addEventListener('DOMContentLoaded', () => makeLocalTime(document.querySelectorAll('.utcTimestamp')));
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<!--/*/<th:block th:replace="~{layout.html :: documentHead ('Hlaeja Management')}"/>/*/-->
<body class="bg-gray-900 text-green-400 min-h-screen flex flex-col">
<!--/*/<th:block th:replace="~{layout.html :: header}"/>/*/-->
<main class="container mx-auto p-4 flex-grow">
<div class="bg-gray-800 p-6 rounded-lg border border-green-900">
<h1 class="text-lg sm:text-xl mb-4 terminal-glow">Device Types</h1>
<hr class="border-green-900 mb-4">
<div class="flex justify-between items-center mb-4">
<div th:if="${pagination.start > pagination.size}" class="text-sm">
Show page <span th:text="${pagination.page}"/> items 0 - 0
</div>
<div th:unless="${pagination.start > pagination.size}" class="text-sm">
Show page <span th:text="${pagination.page}"/>
items <span th:text="${pagination.start}"/> -
<span th:text="${pagination.size}"/>
</div>
<div class="mt-[-2px]">
<a th:href="@{/type/create}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors text-sm">Create New Type</a>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-green-900">
<th class="py-2 px-4 text-left">Name</th>
<th class="py-2 px-4 text-left">Time</th>
<th class="py-2 px-4 text-left">ID</th>
<th class="py-2 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody>
<tr th:if="${items.isEmpty()}">
<td colspan="4" class="py-2 px-4 text-center text-green-600">No accounts found</td>
</tr>
<tr th:each="item : ${items}" class="border-b border-gray-700 hover:bg-gray-700">
<td th:text="${item.name}" class="py-2 px-4"></td>
<td th:data-timestamp="${item.timestamp}" class="py-2 px-4 utcTimestamp"></td>
<td th:text="${item.id}" class="py-2 px-4"></td>
<td class="py-2 px-4"><a th:href="@{/type-{id}(id=${item.id})}">Edit</a></td>
</tr>
</tbody>
</table>
</div>
<!--/*/<th:block th:replace="~{pagination :: pagination('/type', ${pagination})}"/>/*/-->
</div>
</main>
<!--/*/<th:block th:replace="~{layout.html :: footer}"/>/*/-->
<!--/*/<th:block th:replace="~{layout.html :: script}"/>/*/-->
<script>
document.addEventListener('DOMContentLoaded', () => makeLocalTime(document.querySelectorAll('.utcTimestamp')));
</script>
</body>
</html>

View File

@@ -22,6 +22,7 @@
<div id="dropdown-menu" class="hidden absolute right-0 mt-2 w-48 bg-gray-800 border border-green-900 shadow-lg z-10">
<th:block th:if="${remoteUser.hasRole('admin')}">
<a href="/account" class="block px-4 py-2 text-sm hover:bg-gray-700 hover:text-green-300 transition-colors">Account</a>
<a href="/type" class="block px-4 py-2 text-sm hover:bg-gray-700 hover:text-green-300 transition-colors">Device Type</a>
<hr class="dropdown-divider">
</th:block>
<th:block th:if="${remoteUser.authenticated}">

View File

@@ -7,7 +7,7 @@
<!-- Single Message Case -->
<th:block th:case="1">
<span th:if="${error}">Validation Error:</span>
<span th:unless="${error}">Validation:</span>
<span th:unless="${error}">Information:</span>
<span th:text="${messageList[0]}"></span>
</th:block>
<!-- Multiple Messages Case -->

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="pagination(baseUrl, pagination)">
<div class="mt-6 text-sm" th:classappend="${pagination.showSize} ? 'space-y-2' : ''">
<div th:if="${pagination.showSize}" class="mt-6 flex justify-between items-center text-sm">
<span th:if="${pagination.first}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Previous</span>
<a th:unless="${pagination.first}" th:href="@{${baseUrl} + '/page-' + ${pagination.previous} + '/show-' + ${pagination.show}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Previous</a>
<a th:if="${pagination.hasMore}" th:href="@{${baseUrl} + '/page-' + ${pagination.next} + '/show-' + ${pagination.show}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Next</a>
<span th:unless="${pagination.hasMore}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Next</span>
</div>
<div th:unless="${pagination.showSize}" class="mt-6 flex justify-between items-center text-sm">
<span th:if="${pagination.first}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Previous</span>
<a th:unless="${pagination.first}" th:href="@{${baseUrl} + '/page-' + ${pagination.previous}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Previous</a>
<a th:if="${pagination.hasMore}" th:href="@{${baseUrl} + '/page-' + ${pagination.next}}" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded border border-green-900 transition-colors">Next</a>
<span th:unless="${pagination.hasMore}" class="px-3 py-1 bg-gray-800 text-gray-500 rounded border border-green-900">Next</span>
</div>
</div>
</th:block>
</html>

View File

@@ -2,3 +2,5 @@ jwt:
public-key: cert/valid-public-key.pem
account-registry:
url: http://localhost
device-registry:
url: http://localhost