Implement user management

Implement dark mode for UI
This commit is contained in:
grimsi
2024-02-06 01:56:38 +01:00
parent 310bff3b8c
commit 64280579f2
28 changed files with 445 additions and 325 deletions
Binary file not shown.
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.config
enum class Roles(val roleName: String) {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER")
}
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.security
package de.grimsi.gameyfin.config
import com.vaadin.flow.spring.security.VaadinWebSecurity
import de.grimsi.gameyfin.ui.views.LoginView
@@ -8,10 +8,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.provisioning.UserDetailsManager
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
@@ -39,21 +37,8 @@ class SecurityConfiguration : VaadinWebSecurity() {
super.configure(web)
}
/**
* TODO: Just for testing
*/
@Bean
fun userDetailsService(): UserDetailsManager {
val user: UserDetails =
User.withUsername("user")
.password("{noop}user")
.roles("USER")
.build()
val admin: UserDetails =
User.withUsername("admin")
.password("{noop}admin")
.roles("ADMIN")
.build()
return InMemoryUserDetailsManager(user, admin)
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}
@@ -0,0 +1,59 @@
package de.grimsi.gameyfin.setup
import de.grimsi.gameyfin.config.Roles
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.RoleRepository
import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
@Service
class SetupDataLoader(
private val userRepository: UserRepository,
private val roleRepository: RoleRepository,
private val passwordEncoder: PasswordEncoder
) {
private val log = KotlinLogging.logger {}
@Transactional
@EventListener(ApplicationReadyEvent::class)
fun setupRoles() {
createRoleIfNotFound("ROLE_ADMIN")
createRoleIfNotFound("ROLE_USER")
val adminRole: Role = roleRepository.findByRolename(Roles.ADMIN.roleName)!!
val userRole: Role = roleRepository.findByRolename(Roles.USER.roleName)!!
val admin = User("admin")
admin.password = passwordEncoder.encode("admin")
admin.roles = listOf(adminRole)
val user = User("user")
user.password = passwordEncoder.encode("user")
user.roles = listOf(userRole)
userRepository.saveAll(listOf(admin, user))
log.info { "Role setup completed." }
}
@Transactional
fun createRoleIfNotFound(name: String): Role {
log.info { "Creating role $name" }
var role: Role? = roleRepository.findByRolename(name)
if (role == null) {
role = Role(name)
roleRepository.save(role)
}
return role
}
}
@@ -0,0 +1,31 @@
package de.grimsi.gameyfin.setup
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import java.io.IOException
//@Order(1)
//@Component
class SetupFilter(
private val setupService: SetupService
) : Filter {
@Throws(ServletException::class, IOException::class)
override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) {
val req = servletRequest as HttpServletRequest
val res = servletResponse as HttpServletResponse
val isSetupUri = req.requestURI.contains("/v1/setup")
if (setupService.isSetupCompleted() && !isSetupUri ||
!setupService.isSetupCompleted() && isSetupUri
) {
filterChain.doFilter(req, res)
} else {
res.status = HttpStatus.FORBIDDEN.value()
}
}
}
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.setup
import org.springframework.stereotype.Service
@Service
class SetupService {
fun isSetupCompleted() : Boolean {
return false
}
}
@@ -4,26 +4,35 @@ import com.flowingcode.vaadin.addons.fontawesome.FontAwesome
import com.github.mvysny.karibudsl.v10.*
import com.github.mvysny.kaributools.tooltip
import com.vaadin.flow.component.ClickEvent
import com.vaadin.flow.component.applayout.AppLayout
import com.vaadin.flow.component.UI
import com.vaadin.flow.component.dependency.JsModule
import com.vaadin.flow.component.menubar.MenuBarVariant
import com.vaadin.flow.component.notification.Notification
import com.vaadin.flow.component.orderedlayout.FlexComponent
import com.vaadin.flow.component.orderedlayout.HorizontalLayout
import com.vaadin.flow.router.RouterLayout
import com.vaadin.flow.spring.security.AuthenticationContext
import de.grimsi.gameyfin.security.isAdmin
import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.ui.resources.PublicResources
import de.grimsi.gameyfin.ui.services.ThemeService
import de.grimsi.gameyfin.ui.views.SetupView
import de.grimsi.gameyfin.users.util.isAdmin
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
class MainLayout(@field:Transient private val authContext: AuthenticationContext) : KComposite(), RouterLayout {
private val appLayout: AppLayout
@JsModule("./scripts/prefers-color-scheme.js")
class MainLayout(
@field:Transient private val authContext: AuthenticationContext,
@Autowired private val setupService: SetupService,
@Autowired private val themeService: ThemeService
) : KComposite(), RouterLayout {
init {
if (!setupService.isSetupCompleted()) UI.getCurrent().navigate(SetupView::class.java)
val user = authContext.getAuthenticatedUser(UserDetails::class.java).get()
appLayout = ui {
ui {
appLayout {
navbar {
@@ -46,12 +55,30 @@ class MainLayout(@field:Transient private val authContext: AuthenticationContext
colorIndex = user.username[0].code.toByte().mod(6)
}
val toggleDarkModeIcon =
FontAwesome.Solid.CIRCLE_HALF_STROKE.create { _ -> themeService.toggleTheme() }
iconButton(toggleDarkModeIcon)
menuBar {
addThemeVariants(MenuBarVariant.LUMO_ICON)
item(a) {
item(menuItem(FontAwesome.Solid.USER, "Profile"))
if (user.isAdmin()) item(menuItem(FontAwesome.Solid.COG, "Administration"))
item(menuItem(FontAwesome.Solid.QUESTION_CIRCLE, "Help"))
item(
menuItem(
FontAwesome.Solid.USER,
"Profile"
) { _ -> Notification.show("Profile") })
if (user.isAdmin()) {
item(menuItem(FontAwesome.Solid.COG, "Administration") { _ ->
Notification.show(
"Administration"
)
})
}
item(
menuItem(
FontAwesome.Solid.QUESTION_CIRCLE,
"Help"
) { _ -> Notification.show("Help") })
item(menuItem(FontAwesome.Solid.SIGN_OUT, "Sign out") { _ -> authContext.logout() })
}
}
@@ -0,0 +1,47 @@
package de.grimsi.gameyfin.ui.layouts
import com.flowingcode.vaadin.addons.fontawesome.FontAwesome
import com.github.mvysny.karibudsl.v10.*
import com.vaadin.flow.component.UI
import com.vaadin.flow.component.orderedlayout.FlexComponent
import com.vaadin.flow.router.RouterLayout
import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.ui.resources.PublicResources
import de.grimsi.gameyfin.ui.services.ThemeService
import de.grimsi.gameyfin.ui.views.LoginView
import org.springframework.beans.factory.annotation.Autowired
class SetupLayout(
@Autowired private val setupService: SetupService,
@Autowired private val themeService: ThemeService
) : KComposite(), RouterLayout {
init {
if (setupService.isSetupCompleted()) UI.getCurrent().navigate(LoginView::class.java)
ui {
appLayout {
navbar {
flexLayout {
setWidthFull()
alignItems = FlexComponent.Alignment.CENTER
image(PublicResources.GAMEYFIN_LOGO.path) {
setWidthFull()
height = "40px"
className = "header-logo"
}
horizontalLayout {
alignItems = FlexComponent.Alignment.CENTER
val toggleDarkModeIcon =
FontAwesome.Solid.CIRCLE_HALF_STROKE.create { _ -> themeService.toggleTheme() }
iconButton(toggleDarkModeIcon)
}
}
}
}
}
}
}
@@ -0,0 +1,25 @@
package de.grimsi.gameyfin.ui.services
import com.vaadin.flow.component.UI
import com.vaadin.flow.theme.lumo.Lumo
import org.springframework.stereotype.Service
@Service
class ThemeService {
fun isDarkModeActive(): Boolean {
val js = "document.documentElement.getAttribute('theme')"
return UI.getCurrent().element.executeJs(js).toCompletableFuture().get().asString() == "dark"
}
fun toggleTheme() {
setTheme(!isDarkModeActive())
}
fun setTheme(dark: Boolean) {
val js = "document.documentElement.setAttribute('theme', $0)"
UI.getCurrent().element.executeJs(js, if (dark) Lumo.DARK else Lumo.LIGHT)
}
}
@@ -5,8 +5,6 @@ import com.github.mvysny.karibudsl.v10.loginForm
import com.vaadin.flow.component.login.LoginForm
import com.vaadin.flow.component.orderedlayout.FlexComponent
import com.vaadin.flow.component.orderedlayout.VerticalLayout
import com.vaadin.flow.router.BeforeEnterEvent
import com.vaadin.flow.router.BeforeEnterObserver
import com.vaadin.flow.router.PageTitle
import com.vaadin.flow.router.Route
import com.vaadin.flow.server.auth.AnonymousAllowed
@@ -15,7 +13,7 @@ import de.grimsi.gameyfin.ui.resources.PublicResources
@Route("login")
@PageTitle("Login")
@AnonymousAllowed
class LoginView : VerticalLayout(), BeforeEnterObserver {
class LoginView : VerticalLayout() {
private var login: LoginForm
@@ -35,17 +33,4 @@ class LoginView : VerticalLayout(), BeforeEnterObserver {
action = "login"
}
}
override fun beforeEnter(event: BeforeEnterEvent?) {
if (event != null) {
if (event.location
.queryParameters
.parameters
.containsKey("error")
) {
login.isError = true
}
}
}
}
@@ -0,0 +1,27 @@
package de.grimsi.gameyfin.ui.views
import com.github.mvysny.karibudsl.v10.KComposite
import com.github.mvysny.karibudsl.v10.flexLayout
import com.github.mvysny.karibudsl.v10.h1
import com.vaadin.flow.component.orderedlayout.FlexComponent
import com.vaadin.flow.component.orderedlayout.FlexLayout
import com.vaadin.flow.router.Route
import com.vaadin.flow.server.auth.AnonymousAllowed
import de.grimsi.gameyfin.ui.layouts.SetupLayout
@Route("/setup", layout = SetupLayout::class)
@AnonymousAllowed
class SetupView : KComposite() {
init {
ui {
flexLayout {
setWidthFull()
alignItems = FlexComponent.Alignment.CENTER
alignContent = FlexLayout.ContentAlignment.CENTER
h1("Setup View")
}
}
}
}
@@ -0,0 +1,37 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.persistence.UserRepository
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.stereotype.Service
@Service
class UserService(
private val userRepository: UserRepository
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
val user =
userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
return User(
user.username,
user.password,
user.enabled,
true,
true,
true,
getAuthorities(user.roles)
)
}
private fun getAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.rolename) }
}
}
@@ -0,0 +1,23 @@
package de.grimsi.gameyfin.users.entities
import jakarta.annotation.Nullable
import jakarta.persistence.Embeddable
import org.springframework.content.commons.annotations.ContentId
import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType
@Embeddable
class Avatar {
@ContentId
@Nullable
var contentId: String? = null
@ContentLength
@Nullable
var contentLength: Long? = null
@MimeType
@Nullable
var mimeType: String? = null
}
@@ -0,0 +1,18 @@
package de.grimsi.gameyfin.users.entities
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity
class Role(
@NotNull
var rolename: String,
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@ManyToMany(mappedBy = "roles")
var users: Collection<User> = emptyList()
)
@@ -0,0 +1,36 @@
package de.grimsi.gameyfin.users.entities
import jakarta.annotation.Nullable
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity
class User(
@NotNull
var username: String,
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@NotNull
var password: String? = null,
@Nullable
var email: String? = null,
var enabled: Boolean = true,
@Embedded
@Nullable
var avatar: Avatar? = null,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "users_roles",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
)
var roles: Collection<Role> = emptyList()
)
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.users.entities.Avatar
import org.springframework.content.commons.store.ContentStore
interface AvatarContentStore : ContentStore<Avatar, String>
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.users.entities.Role
import org.springframework.data.jpa.repository.JpaRepository
interface RoleRepository : JpaRepository<Role, Long> {
fun findByRolename(roleName: String): Role?
}
@@ -0,0 +1,8 @@
package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository
interface UserRepository : JpaRepository<User, Long> {
fun findByUsername(userName: String): User?
}
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.security
package de.grimsi.gameyfin.users.util
import org.springframework.security.core.userdetails.UserDetails
+7 -1
View File
@@ -2,4 +2,10 @@ vaadin.whitelisted-packages:
- com.vaadin
- org.vaadin
- dev.hilla
- com.flowingcode
- com.flowingcode
spring:
jpa:
properties:
hibernate:
globally_quoted_identifiers: true