Added Login/Logout + ADIM/USER Roles
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A Spring Boot + Vaadin Flow project (Vaadin 25, Spring Boot 4, Java 25). UI is built entirely in server-side Java — no HTML/JS/TypeScript to write for views. Data layer is Spring Data JPA over H2 (file/in-memory, dev only).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run # run the app (dev), http://localhost:8080
|
||||||
|
./mvnw test # run all tests
|
||||||
|
./mvnw test -Dtest=TaskServiceTest # single test class
|
||||||
|
./mvnw test -Dtest=TaskServiceTest#tasks_are_validated_before_they_are_stored # single test method
|
||||||
|
./mvnw package # production build -> target/*.jar
|
||||||
|
java -jar target/*.jar # run the built jar
|
||||||
|
```
|
||||||
|
|
||||||
|
No system Maven is required; always use the `./mvnw` wrapper. There is no separate lint command configured.
|
||||||
|
|
||||||
|
Live hotswap (edit Java, see changes without restart) requires launching via the Vaadin IDE plugin (IntelliJ/VS Code/Eclipse) instead of `spring-boot:run` — not available from the CLI.
|
||||||
|
|
||||||
|
Port defaults to 8080 (override via `server.port` in `src/main/resources/application.properties` or the `PORT` env var).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Feature-package structure**: code is organized by feature/domain under `com.example.<feature>`, not by technical layer. Each feature package is self-contained: entity, repository, service, and a `ui` sub-package for its views. `com.example.examplefeature` is the template feature to copy/replace — its `package-info.java` has a `TODO Remove this package once you have added real features`.
|
||||||
|
|
||||||
|
**Package visibility convention**: types are package-private by default (e.g. `TaskRepository`, `TaskService`, `TaskListView` are not `public`). Only export a type outside its feature package when another package actually needs it. Follow this pattern for new features.
|
||||||
|
|
||||||
|
**Null-safety**: packages are annotated `@NullMarked` (JSpecify) in their `package-info.java`, so all types are non-null by default; use `@Nullable` explicitly where null is allowed (e.g. `Task.dueDate`). Apply `@NullMarked` to any new feature package.
|
||||||
|
|
||||||
|
**Layout/routing**: `MainLayout` (`com.example.base.ui`, annotated `@Layout`) is the shared `AppLayout` shell (drawer nav + header/footer) applied automatically to routed views. Side-nav entries are auto-discovered via `@Menu` on `@Route` view classes (see `TaskListView`) — no manual registration needed. `ViewTitle` is a shared composite for the per-view title bar (drawer toggle + heading).
|
||||||
|
|
||||||
|
**Data access pattern**: `Slice<T>` (via `findAllBy(Pageable)`) is preferred over `Page<T>` for grid data providers, since `Slice` avoids the extra `COUNT` query — see the comment in `TaskRepository`. Vaadin `Grid` is wired to Spring Data via `VaadinSpringDataHelpers.toSpringPageRequest(query)` in an in-memory/lazy `DataProvider` callback (see `TaskListView`).
|
||||||
|
|
||||||
|
**Entities**: JPA entities use `@GeneratedValue(strategy = GenerationType.SEQUENCE)`, expose validation in setters (throwing `IllegalArgumentException`) rather than via bean-validation annotations, and implement `equals`/`hashCode` based on ID only (with a fixed `hashCode`, per the pattern in `Task`).
|
||||||
|
|
||||||
|
**Schema management**: `spring.jpa.hibernate.ddl-auto=update` is used for local dev convenience only. This is explicitly not appropriate for production — the app is meant to move to Flyway (or similar) for real schema migrations before shipping.
|
||||||
|
|
||||||
|
**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Two distinct test styles are used:
|
||||||
|
|
||||||
|
- **Service/integration tests** (`TaskServiceTest`): `@SpringBootTest(webEnvironment = MOCK)` + `@Transactional` (each test rolls back), asserting against the JPA layer directly.
|
||||||
|
- **UI/browserless tests** (`TaskListViewTest`): extend `SpringBrowserlessTest` (from `com.vaadin:browserless-test-spring`), which renders and interacts with actual Vaadin components server-side without a real browser. Use `navigate(ViewClass.class)`, `test(component)` for interactions/assertions, and `$(ComponentClass.class)` for component lookup queries. Views expose package-private fields (e.g. `taskGrid`, `description`, `createBtn`) specifically so tests in the same package can drive them directly.
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
`.mcp.json` configures a Vaadin docs MCP server (`https://mcp.vaadin.com/docs`) and a Playwright MCP server — both available for querying live Vaadin component/API docs and browser automation if needed.
|
||||||
@@ -34,8 +34,7 @@
|
|||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.vaadin</groupId>
|
<groupId>com.vaadin</groupId>
|
||||||
<!-- Replace artifactId with vaadin-core to use only free components -->
|
<artifactId>vaadin-core</artifactId>
|
||||||
<artifactId>vaadin</artifactId>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.vaadin</groupId>
|
<groupId>com.vaadin</groupId>
|
||||||
@@ -54,6 +53,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
@@ -63,6 +66,11 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.vaadin</groupId>
|
<groupId>com.vaadin</groupId>
|
||||||
<artifactId>browserless-test-spring</artifactId>
|
<artifactId>browserless-test-spring</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
This file is auto-generated by Vaadin.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<style>
|
||||||
|
html, body, #outlet {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fix app height for iOS home screen apps:
|
||||||
|
- 100% doesn't cover the entire screen.
|
||||||
|
- 100vh would work on iOS 18 but not on iOS 17 (too tall in Safari, correct in standalone mode).
|
||||||
|
- 100lvh is too tall in Safari. 100svh is too short in standalone apps.
|
||||||
|
- 100dvh doesn't seem to work on first render when launched from home screen (initially like 100svh, then changes to 100lvh when you try to scroll the view).
|
||||||
|
- So, we use 100% for the regular case (Safari and other browsers), and 100lvh for iOS home screen apps. Note, that the `display-mode: standalone` media query only works if the webmanifest defines `"display": "standalone"`.
|
||||||
|
*/
|
||||||
|
@supports (-webkit-touch-callout: none) {
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
html {
|
||||||
|
height: 100lvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- This outlet div is where the views are rendered -->
|
||||||
|
<div id="outlet"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,7 @@ import com.vaadin.flow.component.Unit;
|
|||||||
import com.vaadin.flow.component.applayout.AppLayout;
|
import com.vaadin.flow.component.applayout.AppLayout;
|
||||||
import com.vaadin.flow.component.avatar.Avatar;
|
import com.vaadin.flow.component.avatar.Avatar;
|
||||||
import com.vaadin.flow.component.avatar.AvatarVariant;
|
import com.vaadin.flow.component.avatar.AvatarVariant;
|
||||||
|
import com.vaadin.flow.component.button.Button;
|
||||||
import com.vaadin.flow.component.html.Span;
|
import com.vaadin.flow.component.html.Span;
|
||||||
import com.vaadin.flow.component.icon.Icon;
|
import com.vaadin.flow.component.icon.Icon;
|
||||||
import com.vaadin.flow.component.icon.SvgIcon;
|
import com.vaadin.flow.component.icon.SvgIcon;
|
||||||
@@ -14,11 +15,18 @@ import com.vaadin.flow.component.sidenav.SideNavItem;
|
|||||||
import com.vaadin.flow.router.Layout;
|
import com.vaadin.flow.router.Layout;
|
||||||
import com.vaadin.flow.server.menu.MenuConfiguration;
|
import com.vaadin.flow.server.menu.MenuConfiguration;
|
||||||
import com.vaadin.flow.server.menu.MenuEntry;
|
import com.vaadin.flow.server.menu.MenuEntry;
|
||||||
|
import com.vaadin.flow.spring.security.AuthenticationContext;
|
||||||
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
|
||||||
@Layout
|
@Layout
|
||||||
|
@PermitAll
|
||||||
public final class MainLayout extends AppLayout {
|
public final class MainLayout extends AppLayout {
|
||||||
|
|
||||||
MainLayout() {
|
// AuthenticationContext is not Serializable by design, so this field must stay transient.
|
||||||
|
private final transient AuthenticationContext authenticationContext;
|
||||||
|
|
||||||
|
MainLayout(AuthenticationContext authenticationContext) {
|
||||||
|
this.authenticationContext = authenticationContext;
|
||||||
setPrimarySection(Section.DRAWER);
|
setPrimarySection(Section.DRAWER);
|
||||||
addToDrawer(createApplicationHeader(), createApplicationDrawer(), createApplicationFooter());
|
addToDrawer(createApplicationHeader(), createApplicationDrawer(), createApplicationFooter());
|
||||||
}
|
}
|
||||||
@@ -45,12 +53,21 @@ public final class MainLayout extends AppLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Component createApplicationFooter() {
|
private Component createApplicationFooter() {
|
||||||
var footer = new VerticalLayout(new Span("Made with ❤️ with Vaadin"));
|
var footer = new VerticalLayout(createUserInfo(), new Span("Made with ❤️ with Vaadin"));
|
||||||
footer.setAlignItems(FlexComponent.Alignment.CENTER);
|
footer.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
footer.addClassName("app-footer");
|
footer.addClassName("app-footer");
|
||||||
return footer;
|
return footer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Component createUserInfo() {
|
||||||
|
var username = authenticationContext.getPrincipalName().orElse("");
|
||||||
|
var logout = new Button("Logout", event -> authenticationContext.logout());
|
||||||
|
|
||||||
|
var userInfo = new HorizontalLayout(new Span(username), logout);
|
||||||
|
userInfo.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
private SideNav createSideNav() {
|
private SideNav createSideNav() {
|
||||||
var nav = new SideNav();
|
var nav = new SideNav();
|
||||||
nav.setMinWidth(200, Unit.PIXELS);
|
nav.setMinWidth(200, Unit.PIXELS);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import com.vaadin.flow.router.Menu;
|
import com.vaadin.flow.router.Menu;
|
||||||
import com.vaadin.flow.router.PageTitle;
|
import com.vaadin.flow.router.PageTitle;
|
||||||
import com.vaadin.flow.router.Route;
|
import com.vaadin.flow.router.Route;
|
||||||
|
import jakarta.annotation.security.PermitAll;
|
||||||
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -26,6 +27,7 @@ import static com.vaadin.flow.spring.data.VaadinSpringDataHelpers.toSpringPageRe
|
|||||||
@Route(value = "")
|
@Route(value = "")
|
||||||
@PageTitle("Task List")
|
@PageTitle("Task List")
|
||||||
@Menu(order = 0, icon = "icons/clipboard-check.svg", title = "Task List")
|
@Menu(order = 0, icon = "icons/clipboard-check.svg", title = "Task List")
|
||||||
|
@PermitAll
|
||||||
class TaskListView extends VerticalLayout {
|
class TaskListView extends VerticalLayout {
|
||||||
|
|
||||||
private final TaskService taskService;
|
private final TaskService taskService;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
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;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AppUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
AppUserDetailsService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
var user = userRepository.findByUsername(username);
|
||||||
|
if (user == null) {
|
||||||
|
throw new UsernameNotFoundException("No user found with username '" + username + "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorities = user.getRoles().stream()
|
||||||
|
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return org.springframework.security.core.userdetails.User
|
||||||
|
.withUsername(user.getUsername())
|
||||||
|
.password(user.getPasswordHash())
|
||||||
|
.disabled(!user.isEnabled())
|
||||||
|
.authorities(List.copyOf(authorities))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds demo accounts for local development.
|
||||||
|
* <p>
|
||||||
|
* Not appropriate for production: the app has no persistent datasource configured
|
||||||
|
* (see application.properties), so H2 is in-memory and these accounts are recreated
|
||||||
|
* on every restart, and any accounts created at runtime are lost.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class DemoUserSeeder implements ApplicationRunner {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
DemoUserSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
if (userRepository.count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = new User("user", passwordEncoder.encode("user"));
|
||||||
|
user.addRole(Role.USER);
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
var admin = new User("admin", passwordEncoder.encode("admin"));
|
||||||
|
admin.addRole(Role.ADMIN);
|
||||||
|
userRepository.save(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER,
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import com.example.security.ui.LoginView;
|
||||||
|
import com.vaadin.flow.spring.security.VaadinSecurityConfigurer;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizedUrl;
|
||||||
|
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
return http.with(VaadinSecurityConfigurer.vaadin(), configurer -> {
|
||||||
|
// Vaadin 25 denies any request not otherwise matched by default, which
|
||||||
|
// blocks framework error views (e.g. RouteNotFoundError) at the HTTP layer
|
||||||
|
// before navigation runs. Routes themselves stay protected by the
|
||||||
|
// secured-route matchers and navigation access control below.
|
||||||
|
configurer.loginView(LoginView.class).anyRequest(AuthorizedUrl::permitAll);
|
||||||
|
}).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
PasswordEncoder passwordEncoder() {
|
||||||
|
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.jspecify.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "app_user")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
public static final int USERNAME_MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.SEQUENCE)
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "username", nullable = false, unique = true, length = USERNAME_MAX_LENGTH)
|
||||||
|
private String username = "";
|
||||||
|
|
||||||
|
@Column(name = "password_hash", nullable = false)
|
||||||
|
private String passwordHash = "";
|
||||||
|
|
||||||
|
@Column(name = "enabled", nullable = false)
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(name = "app_user_role", joinColumns = @JoinColumn(name = "user_id"))
|
||||||
|
@Column(name = "role", nullable = false)
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private Set<Role> roles = new HashSet<>();
|
||||||
|
|
||||||
|
protected User() { // To keep Hibernate happy
|
||||||
|
}
|
||||||
|
|
||||||
|
public User(String username, String passwordHash) {
|
||||||
|
setUsername(username);
|
||||||
|
setPasswordHash(passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @Nullable Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(String username) {
|
||||||
|
if (username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username must not be blank");
|
||||||
|
}
|
||||||
|
if (username.length() > USERNAME_MAX_LENGTH) {
|
||||||
|
throw new IllegalArgumentException("Username length exceeds " + USERNAME_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
if (passwordHash.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Password hash must not be blank");
|
||||||
|
}
|
||||||
|
this.passwordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Role> getRoles() {
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addRole(Role role) {
|
||||||
|
roles.add(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (obj == null || !getClass().isAssignableFrom(obj.getClass())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (obj == this) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
User other = (User) obj;
|
||||||
|
return getId() != null && getId().equals(other.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
// Hashcode should never change during the lifetime of an object. Because of
|
||||||
|
// this we can't use getId() to calculate the hashcode. Unless you have sets
|
||||||
|
// with lots of entities in them, returning the same hashcode should not be a
|
||||||
|
// problem.
|
||||||
|
return getClass().hashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.Nullable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
User findByUsername(String username);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@NullMarked
|
||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.NullMarked;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.example.security.ui;
|
||||||
|
|
||||||
|
import com.vaadin.flow.component.html.Main;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Public: referenced from com.example.security.SecurityConfig via loginView(LoginView.class).
|
||||||
|
@Route(value = "login", autoLayout = false)
|
||||||
|
@PageTitle("Login")
|
||||||
|
@AnonymousAllowed
|
||||||
|
public class LoginView extends Main implements BeforeEnterObserver {
|
||||||
|
|
||||||
|
final LoginForm login;
|
||||||
|
|
||||||
|
public LoginView() {
|
||||||
|
login = new LoginForm();
|
||||||
|
login.setAction("login");
|
||||||
|
|
||||||
|
var layout = new VerticalLayout();
|
||||||
|
layout.setAlignItems(FlexComponent.Alignment.CENTER);
|
||||||
|
layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
|
||||||
|
layout.add(login);
|
||||||
|
layout.setSizeFull();
|
||||||
|
|
||||||
|
add(layout);
|
||||||
|
setSizeFull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beforeEnter(BeforeEnterEvent event) {
|
||||||
|
if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
|
||||||
|
login.setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@NullMarked
|
||||||
|
package com.example.security.ui;
|
||||||
|
|
||||||
|
import org.jspecify.annotations.NullMarked;
|
||||||
@@ -4,6 +4,7 @@ import com.vaadin.browserless.SpringBrowserlessTest;
|
|||||||
import com.vaadin.flow.component.notification.Notification;
|
import com.vaadin.flow.component.notification.Notification;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -12,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||||
@Transactional
|
@Transactional
|
||||||
|
@WithMockUser(roles = "USER")
|
||||||
class TaskListViewTest extends SpringBrowserlessTest {
|
class TaskListViewTest extends SpringBrowserlessTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||||
|
@Transactional
|
||||||
|
class AppUserDetailsServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
AppUserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void seeded_user_has_user_role_and_matching_password() {
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername("user");
|
||||||
|
|
||||||
|
assertThat(userDetails.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_USER");
|
||||||
|
assertThat(userDetails.getPassword()).isNotEqualTo("user");
|
||||||
|
assertThat(passwordEncoder.matches("user", userDetails.getPassword())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void seeded_admin_has_admin_role() {
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername("admin");
|
||||||
|
|
||||||
|
assertThat(userDetails.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_ADMIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_username_throws() {
|
||||||
|
assertThatThrownBy(() -> userDetailsService.loadUserByUsername("nobody"))
|
||||||
|
.isInstanceOf(UsernameNotFoundException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.example.security;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class UserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blank_username_is_rejected() {
|
||||||
|
assertThatThrownBy(() -> new User(" ", "hash")).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void username_exceeding_max_length_is_rejected() {
|
||||||
|
assertThatThrownBy(() -> new User("x".repeat(User.USERNAME_MAX_LENGTH + 1), "hash"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blank_password_hash_is_rejected() {
|
||||||
|
assertThatThrownBy(() -> new User("someone", " ")).isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.example.security.ui;
|
||||||
|
|
||||||
|
import com.vaadin.browserless.SpringBrowserlessTest;
|
||||||
|
import com.vaadin.flow.component.UI;
|
||||||
|
import com.vaadin.flow.router.QueryParameters;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.test.context.support.WithAnonymousUser;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||||
|
@Transactional
|
||||||
|
class LoginViewTest extends SpringBrowserlessTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithAnonymousUser
|
||||||
|
void anonymous_user_is_redirected_to_login() {
|
||||||
|
navigate("", LoginView.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithAnonymousUser
|
||||||
|
void login_form_shows_error_state_when_error_query_parameter_present() {
|
||||||
|
navigate(LoginView.class);
|
||||||
|
// navigate(String, Class) rejects a "?" in the location, so the query
|
||||||
|
// parameter has to be attached via the UI.navigate(String, QueryParameters) overload.
|
||||||
|
UI.getCurrent().navigate("login", QueryParameters.of("error", ""));
|
||||||
|
var view = (LoginView) getCurrentView();
|
||||||
|
assertThat(view.login.isError()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithAnonymousUser
|
||||||
|
void login_form_has_no_error_state_by_default() {
|
||||||
|
var view = navigate(LoginView.class);
|
||||||
|
assertThat(view.login.isError()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user