summaryrefslogtreecommitdiff
path: root/spring-boot/10-role-based-security/src/main
diff options
context:
space:
mode:
authorKamal Wickramanayake <kamal@inbox.lk>2026-06-20 18:25:56 +0530
committerKamal Wickramanayake <kamal@inbox.lk>2026-06-20 18:25:56 +0530
commit41b068b6d48f9d82babd1cfce3520ad0b28be861 (patch)
tree0a9db394d2f14b7f14c3b1d167f56d4dfecd4563 /spring-boot/10-role-based-security/src/main
parent8c66a82e0caa71c3bb1c1dc40d91272f51c1c0f5 (diff)
Added Spring Boot role based security sample application
Diffstat (limited to 'spring-boot/10-role-based-security/src/main')
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/Application.java12
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/HomeController.java13
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/config/SecurityConfig.java36
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactController.java49
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactForm.java45
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/security/entity/User.java147
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/security/repository/UserRepository.java13
-rw-r--r--spring-boot/10-role-based-security/src/main/java/com/example/spring/security/service/CustomUserDetailsService.java25
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/application.properties1
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/config/application-dev.properties33
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/data.sql5
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/static/css/main.css92
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/templates/contact/form.html30
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/templates/contact/result.html14
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/templates/index.html18
-rw-r--r--spring-boot/10-role-based-security/src/main/resources/templates/layout/main.html36
16 files changed, 569 insertions, 0 deletions
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/Application.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/Application.java
new file mode 100644
index 0000000..4393f3f
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/Application.java
@@ -0,0 +1,12 @@
+package com.example.spring;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class Application {
+
+ public static void main(String[] args) throws Exception {
+ SpringApplication.run(Application.class, args);
+ }
+} \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/HomeController.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/HomeController.java
new file mode 100644
index 0000000..d6ead56
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/HomeController.java
@@ -0,0 +1,13 @@
+package com.example.spring;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+public class HomeController {
+
+ @RequestMapping("/")
+ public String home() {
+ return "index";
+ }
+}
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/config/SecurityConfig.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/config/SecurityConfig.java
new file mode 100644
index 0000000..8359405
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/config/SecurityConfig.java
@@ -0,0 +1,36 @@
+package com.example.spring.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+@EnableWebSecurity
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ // An Argon2 password encoder with a salt length of 16 bytes, a hash length of 32 bytes, parallelism of 1, memory cost of 1 << 14 (i.e. 16MB) and 2 iterations.
+ return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
+ }
+
+ // @Bean
+ // public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+ // http
+ // //.csrf(csrf -> csrf.disable()) // Disable for stateless REST APIs
+ // .authorizeHttpRequests(auth -> auth
+ // .requestMatchers("/somepath/**").permitAll() // Publicly available
+ // .requestMatchers("/admin/**").hasRole("ADMIN") // Requires ROLE_ADMIN
+ // .requestMatchers("/contact/**").hasAnyRole("USER", "ADMIN")
+ // .anyRequest().authenticated()
+ // );
+
+ // return http.build();
+ // }
+} \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactController.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactController.java
new file mode 100644
index 0000000..abc7e83
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactController.java
@@ -0,0 +1,49 @@
+package com.example.spring.contact.controller;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import jakarta.validation.Valid;
+
+@Controller
+public class ContactController {
+
+ /*
+ * Some values that can be used with @PreAuthorize():
+ *
+ * hasRole('ADMIN'): Checks if the user contains the authority ROLE_ADMIN.
+ *
+ * hasAnyRole('USER', 'ADMIN'): Passes if the user has at least one matching
+ * authority.
+ *
+ * isAuthenticated(): Verifies that the client is logged into the system
+ */
+
+ @GetMapping("/contact")
+ @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
+ public String showForm(Model model) {
+ model.addAttribute("contactForm", new ContactForm());
+ return "contact/form";
+ }
+
+ @PostMapping("/contact")
+ @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
+ public String processSubmission(@Valid @ModelAttribute ContactForm contactForm, BindingResult bindingResult,
+ Model model) {
+ if (bindingResult.hasErrors()) {
+ return "contact/form";
+ }
+
+ // Do whatever needed with the submitted data here.
+ // For example, invoke a method in a service/componet bean to send an email to
+ // contact person.
+
+ return "contact/result";
+ }
+
+}
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactForm.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactForm.java
new file mode 100644
index 0000000..925999b
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/contact/controller/ContactForm.java
@@ -0,0 +1,45 @@
+package com.example.spring.contact.controller;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+public class ContactForm {
+
+ @Pattern(regexp = "^[ a-zA-Z]+$", message = "Name should only include English letters")
+ @NotBlank(message = "Name is required")
+ private String name;
+
+ @Email(message = "Must be a valid email")
+ @NotBlank(message = "Email is required")
+ private String email;
+
+ @Size(min = 10, message = "Message must be at least 10 characters long")
+ private String message;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+}
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/entity/User.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/entity/User.java
new file mode 100644
index 0000000..72c413a
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/entity/User.java
@@ -0,0 +1,147 @@
+package com.example.spring.security.entity;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Table;
+
+/*
+ @Table - Used to set the database table name explicitely.
+*/
+@Entity
+@Table(name = "user_account")
+public class User implements UserDetails {
+
+ /*
+ * GenerationType.IDENTITY - Auto generate value based on database's native auto
+ * increment feature
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(unique = true)
+ private String username;
+
+ private String password;
+
+ @Column(nullable = false)
+ private String description;
+
+ @CreationTimestamp
+ @Column(updatable = false, name = "created_at")
+ private Date createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at")
+ private Date updatedAt;
+
+ /*
+ * @ElementCollection: Marks the field as a collection of basic types (Strings,
+ * Integers, etc.) or embeddable objects.
+ *
+ * @CollectionTable: Customizes the junction table details. If omitted, JPA
+ * generates a default table name combining the entity name and the field name
+ * (e.g., User_roles).
+ *
+ * @Column: Defines the column name for the String values inside the collection
+ * table.
+ *
+ * Fetch Type: By default, element collections use FetchType.LAZY. If you access
+ * the set outside of an active transaction or Hibernate session, it throws a
+ * LazyInitializationException. You can switch it to fetch = FetchType.EAGER to
+ * load the strings immediately alongside the main entity.
+ *
+ */
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "user_roles", // Name of the separate collection table
+ joinColumns = @JoinColumn(name = "user_id") // Foreign key linking back to this entity
+ )
+ @Column(name = "role_name") // Name of the column storing the actual String values
+ private Set<String> roles = new HashSet<>(); // e.g., ["ADMIN", "USER"]
+
+ @Override
+ public Collection<? extends GrantedAuthority> getAuthorities() {
+ return roles.stream()
+ .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public String getPassword() {
+ return password;
+ }
+
+ @Override
+ public String getUsername() {
+ return username;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Date getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Date createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Date getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Date updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public Set<String> getRoles() {
+ return roles;
+ }
+
+ public void setRoles(Set<String> roles) {
+ this.roles = roles;
+ }
+
+} \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/repository/UserRepository.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/repository/UserRepository.java
new file mode 100644
index 0000000..f67039c
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/repository/UserRepository.java
@@ -0,0 +1,13 @@
+package com.example.spring.security.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.repository.ListCrudRepository;
+import org.springframework.stereotype.Repository;
+
+import com.example.spring.security.entity.User;
+
+@Repository
+public interface UserRepository extends ListCrudRepository<User, Long> {
+ Optional<User> findByUsername(String username);
+}
diff --git a/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/service/CustomUserDetailsService.java b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/service/CustomUserDetailsService.java
new file mode 100644
index 0000000..cdced4a
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/java/com/example/spring/security/service/CustomUserDetailsService.java
@@ -0,0 +1,25 @@
+package com.example.spring.security.service;
+
+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 com.example.spring.security.repository.UserRepository;
+
+@Service
+public class CustomUserDetailsService implements UserDetailsService {
+
+ private final UserRepository userRepository;
+
+ public CustomUserDetailsService(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ return userRepository.findByUsername(username)
+ .orElseThrow(() -> new UsernameNotFoundException("User not found"));
+ }
+
+} \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/application.properties b/spring-boot/10-role-based-security/src/main/resources/application.properties
new file mode 100644
index 0000000..8a8c4c2
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/application.properties
@@ -0,0 +1 @@
+# Look at resources/config/application-dev.properties for settings for dev profile. \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/config/application-dev.properties b/spring-boot/10-role-based-security/src/main/resources/config/application-dev.properties
new file mode 100644
index 0000000..50973c7
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/config/application-dev.properties
@@ -0,0 +1,33 @@
+# This file contains settings used during development.
+# If using Maven to run, try the following command:
+# mvn spring-boot:run -Dspring-boot.run.profiles=dev
+
+# Web related settings
+
+# Disable caching for static resources
+spring.web.resources.cache.period=0
+spring.thymeleaf.cache=false
+
+# Optional: Tell DevTools to watch changes but never restart the backend server for CSS
+spring.devtools.restart.exclude=static/**,public/**,templates/**
+
+
+# Database related settings
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/springboot
+spring.datasource.username=springboot
+spring.datasource.password=abc123
+
+# Create/update/drop database tables automatically?
+# In production, setting value to none is better.
+# Possible values: none, validate, update, create, create-drop
+spring.jpa.hibernate.ddl-auto=create-drop
+
+# Print sql that gets executed. Good for developers. Set to false in production.
+spring.jpa.show-sql=true
+
+# Always execute initialization scripts (resources/schema.sql, resources/data.sql) on startup
+spring.sql.init.mode=always
+
+# Data source initialization scripts (schema.sql and data.sql) are executed after Hibernate/JPA auto-creates database tables.
+spring.jpa.defer-datasource-initialization=true
diff --git a/spring-boot/10-role-based-security/src/main/resources/data.sql b/spring-boot/10-role-based-security/src/main/resources/data.sql
new file mode 100644
index 0000000..5318008
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/data.sql
@@ -0,0 +1,5 @@
+INSERT INTO user_account (username, password, description) VALUES ('admin','$argon2id$v=19$m=16,t=2,p=1$YnJERWhhY0RxV1hGMFNWVg$OHRvhGZx1x9KqCoVZOfUD2TkJc+HQB5SamgYejw+K2Y', 'Admin user');
+INSERT INTO user_account (username, password, description) VALUES ('user1','$argon2id$v=19$m=16,t=2,p=1$YnJERWhhY0RxV1hGMFNWVg$OHRvhGZx1x9KqCoVZOfUD2TkJc+HQB5SamgYejw+K2Y', 'Normal user 1');
+
+INSERT INTO user_roles (user_id, role_name) VALUES (1, 'ADMIN');
+INSERT INTO user_roles (user_id, role_name) VALUES (2, 'USER'); \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/static/css/main.css b/spring-boot/10-role-based-security/src/main/resources/static/css/main.css
new file mode 100644
index 0000000..edd53ea
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/static/css/main.css
@@ -0,0 +1,92 @@
+body {
+ font-size: 1.125em;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ line-height: 1.6em;
+ color: rgb(32, 34, 36);;
+ background-color: #FFF;
+ padding: 10px 5px;
+}
+
+a {
+ color: #1475AC;
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+ul li {
+ padding-bottom: 0.5em;
+}
+
+header {
+ text-align: left;
+ font-size: 1.2em;
+}
+
+header h1 {
+ margin-top: 0;
+ margin-bottom: 20px;
+}
+
+header a {
+ color: #000000;
+ text-decoration: none;
+}
+
+.main-nav {
+ margin-top: 0;
+ padding: 0;
+}
+
+.main-nav ul {
+ margin: 0;
+ padding : 0;
+ list-style-type: none;
+ background-color: #F6F8F8;
+ border: 1px solid #eee;
+}
+
+.main-nav ul li {
+ margin-left: 0;
+ display: inline;
+ padding: 0 8px;
+ border-left: 1px solid #F9F9FF;
+ border-right: 1px solid #F9F9FF;
+}
+
+.main-nav ul li:hover {
+ background-color: white;
+ border-left: 1px solid #addfff;
+ border-right: 1px solid #addfff;
+}
+
+.main-nav ul li a {
+ color: #555;
+ font-weight: bold;
+}
+
+.main-nav ul li ai:hover {
+ color: #000;
+}
+
+footer {
+ margin-top: 20px;
+ font-size: 85%;
+ border-top: 1px solid #CCC;
+}
+
+.form-field-container {
+ margin-top: 8px;
+ margin-bottom: 8px;
+}
+
+.form-field-container label {
+ display: inline-block;
+ width: 5em;
+}
+
+.form-field-container .validation-error {
+ color: red;
+} \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/templates/contact/form.html b/spring-boot/10-role-based-security/src/main/resources/templates/contact/form.html
new file mode 100644
index 0000000..7debe1f
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/templates/contact/form.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout/main :: layout(title='Contact Us', content=~{::main})}">
+
+<body>
+ <main>
+ <form th:action="@{/contact}" th:object="${contactForm}" method="post">
+ <div class="form-field-container">
+ <label>Name</label>
+ <input type="text" th:field="*{name}" placeholder="Your name" />
+ <div class="validation-error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
+ </div>
+
+ <div class="form-field-container">
+ <label>Email</label>
+ <input type="email" th:field="*{email}" placeholder="Your email" />
+ <div class="validation-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></div>
+ </div>
+
+ <div class="form-field-container">
+ <label>Message</label>
+ <textarea th:field="*{message}" placeholder="Your message"></textarea>
+ <div class="validation-error" th:if="${#fields.hasErrors('message')}" th:errors="*{message}"></div>
+ </div>
+
+ <button type="submit">Send</button>
+ </form>
+ </main>
+</body>
+
+</html> \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/templates/contact/result.html b/spring-boot/10-role-based-security/src/main/resources/templates/contact/result.html
new file mode 100644
index 0000000..d1bda9a
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/templates/contact/result.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html xmlns:th="http://www.thymeleaf.org" th:replace="~{layout/main :: layout(title='Contact Us', content=~{::main})}">
+
+<body>
+ <main>
+ <p>We received your message.</p>
+
+ <p th:text="'Thank you ' + ${contactForm.name} + '.'" />
+
+ <a th:href="@{/contact}">Try again</a>
+ </main>
+</body>
+
+</html> \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/templates/index.html b/spring-boot/10-role-based-security/src/main/resources/templates/index.html
new file mode 100644
index 0000000..dce7a4e
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/templates/index.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html xmlns:th="http://www.thymeleaf.org"
+ xmlns:sec="http://thymeleaf.org/extras/spring-security"
+ th:replace="~{layout/main :: layout(title='Welcome', content=~{::main})}">
+
+<body>
+ <main>
+ <p>Hello, <span sec:authentication="name">Username Placeholder</span>!</p>
+
+ <p>Hello, [[${#authentication.name}]]!</p>
+
+ <p>This is the home page content.</p>
+
+ <p>Use the navigation links to explore other pages.</p>
+ </main>
+</body>
+
+</html> \ No newline at end of file
diff --git a/spring-boot/10-role-based-security/src/main/resources/templates/layout/main.html b/spring-boot/10-role-based-security/src/main/resources/templates/layout/main.html
new file mode 100644
index 0000000..df96fc5
--- /dev/null
+++ b/spring-boot/10-role-based-security/src/main/resources/templates/layout/main.html
@@ -0,0 +1,36 @@
+<!DOCTYPE HTML>
+<html xmlns:th="http://www.thymeleaf.org"
+ xmlns:sec="http://thymeleaf.org/extras/spring-security"
+ th:fragment="layout(title, content)">
+
+<head>
+ <title th:text="|${title} - ABC App|">Base title not shown</title>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <link rel="stylesheet" th:href="@{/css/main.css}" />
+</head>
+
+<body>
+ <header>
+ <h1>ABC App</h1>
+ </header>
+
+ <nav class="main-nav">
+ <ul>
+ <li><a th:href="@{/}">Home</a></li>
+ <li><a th:href="@{/contact}">Contact Us</a></li>
+ <li sec:authorize="hasRole('ADMIN')"><a th:href="@{/admin}">Admin</a></li>
+ <li sec:authorize="isAuthenticated()"><a th:href="@{/profile/{name}(name=${#authentication.name})}">Profile</a></li>
+ <li sec:authorize="isAuthenticated()"><a th:href="@{/logout}">Logout</a></li>
+ </ul>
+ </nav>
+
+ <h3 th:text="|${title}|">Page content area title</h3>
+
+ <th:block th:replace="${content}" />
+
+ <footer>
+ Copyright &copy; 2026 Example Company. All rights reserved.
+ </footer>
+</body>
+
+</html> \ No newline at end of file