From 78bc99a6eda6d3364f49ede773b6ae7e1e8ff564 Mon Sep 17 00:00:00 2001 From: SEBASTIAN LAGUNA FUNES <75394396+sebaslagu@users.noreply.github.com> Date: Mon, 20 Oct 2025 19:58:33 -0500 Subject: [PATCH 1/5] deps: add Thymeleaf starter for MPA views --- build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index b2218a3..cdfe88a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") @@ -49,4 +50,4 @@ kotlin { tasks.withType { useJUnitPlatform() -} +} \ No newline at end of file From 2857a0f0abefa85d49a1369a6134fa89431c1512 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:08:53 +0000 Subject: [PATCH 2/5] Initial plan From b58d41282bc9eead6a5520ad65f744f42caf067b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:16:50 +0000 Subject: [PATCH 3/5] Add MPA layer with Thymeleaf - controllers, templates, and static assets Co-authored-by: sebaslagu <75394396+sebaslagu@users.noreply.github.com> --- .../controllers/web/WebAuthController.kt | 115 ++++++ .../controllers/web/WebHomeController.kt | 13 + .../controllers/web/WebNoteController.kt | 76 ++++ .../security/JwtAuthFilter.kt | 24 +- .../security/SecurityConfig.kt | 28 +- src/main/resources/static/css/styles.css | 357 ++++++++++++++++++ src/main/resources/static/js/auth.js | 55 +++ src/main/resources/static/js/notes.js | 53 +++ src/main/resources/templates/home.html | 32 ++ src/main/resources/templates/login.html | 53 +++ src/main/resources/templates/notes.html | 95 +++++ src/main/resources/templates/register.html | 49 +++ 12 files changed, 944 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebAuthController.kt create mode 100644 src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt create mode 100644 src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebNoteController.kt create mode 100644 src/main/resources/static/css/styles.css create mode 100644 src/main/resources/static/js/auth.js create mode 100644 src/main/resources/static/js/notes.js create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/notes.html create mode 100644 src/main/resources/templates/register.html diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebAuthController.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebAuthController.kt new file mode 100644 index 0000000..4d023f2 --- /dev/null +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebAuthController.kt @@ -0,0 +1,115 @@ +package com.plcoding.spring_boot_crash_course.controllers.web + +import com.plcoding.spring_boot_crash_course.security.AuthService +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Pattern +import org.springframework.security.authentication.BadCredentialsException +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 org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.server.ResponseStatusException + +@Controller +@RequestMapping("/web") +class WebAuthController( + private val authService: AuthService +) { + + data class LoginForm( + @field:Email(message = "Invalid email format.") + val email: String = "", + val password: String = "" + ) + + data class RegisterForm( + @field:Email(message = "Invalid email format.") + val email: String = "", + @field:Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{9,}\$", + message = "Password must be at least 9 characters long and contain at least one digit, uppercase and lowercase character." + ) + val password: String = "" + ) + + @GetMapping("/login") + fun loginPage(model: Model): String { + model.addAttribute("loginForm", LoginForm()) + return "login" + } + + @PostMapping("/login") + fun login( + @Valid @ModelAttribute loginForm: LoginForm, + bindingResult: BindingResult, + model: Model, + response: HttpServletResponse + ): String { + if (bindingResult.hasErrors()) { + return "login" + } + + return try { + val tokenPair = authService.login(loginForm.email, loginForm.password) + + // Set access token cookie (15 minutes) + val accessCookie = Cookie("access_token", tokenPair.accessToken) + accessCookie.isHttpOnly = true + accessCookie.path = "/" + accessCookie.maxAge = 15 * 60 // 15 minutes + accessCookie.secure = false // Set to true in production with HTTPS + response.addCookie(accessCookie) + + // Set refresh token cookie (30 days) + val refreshCookie = Cookie("refresh_token", tokenPair.refreshToken) + refreshCookie.isHttpOnly = true + refreshCookie.path = "/" + refreshCookie.maxAge = 30 * 24 * 60 * 60 // 30 days + refreshCookie.secure = false // Set to true in production with HTTPS + response.addCookie(refreshCookie) + + "redirect:/web/notes" + } catch (e: BadCredentialsException) { + model.addAttribute("error", "Invalid credentials.") + "login" + } catch (e: Exception) { + model.addAttribute("error", "An error occurred during login.") + "login" + } + } + + @GetMapping("/register") + fun registerPage(model: Model): String { + model.addAttribute("registerForm", RegisterForm()) + return "register" + } + + @PostMapping("/register") + fun register( + @Valid @ModelAttribute registerForm: RegisterForm, + bindingResult: BindingResult, + model: Model + ): String { + if (bindingResult.hasErrors()) { + return "register" + } + + return try { + authService.register(registerForm.email, registerForm.password) + model.addAttribute("success", "Registration successful! Please log in.") + "redirect:/web/login?registered" + } catch (e: ResponseStatusException) { + model.addAttribute("error", e.reason ?: "Registration failed.") + "register" + } catch (e: Exception) { + model.addAttribute("error", "An error occurred during registration.") + "register" + } + } +} diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt new file mode 100644 index 0000000..4f8c868 --- /dev/null +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt @@ -0,0 +1,13 @@ +package com.plcoding.spring_boot_crash_course.controllers.web + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping + +@Controller +class WebHomeController { + + @GetMapping("/", "/web/home") + fun home(): String { + return "home" + } +} diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebNoteController.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebNoteController.kt new file mode 100644 index 0000000..1b1e167 --- /dev/null +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebNoteController.kt @@ -0,0 +1,76 @@ +package com.plcoding.spring_boot_crash_course.controllers.web + +import com.plcoding.spring_boot_crash_course.database.model.Note +import com.plcoding.spring_boot_crash_course.database.repository.NoteRepository +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import org.bson.types.ObjectId +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.validation.BindingResult +import org.springframework.web.bind.annotation.* +import java.time.Instant + +@Controller +@RequestMapping("/web/notes") +class WebNoteController( + private val noteRepository: NoteRepository +) { + + data class NoteForm( + val id: String? = null, + @field:NotBlank(message = "Title can't be blank.") + val title: String = "", + val content: String = "", + val color: Long = 0xFFFFFFFF + ) + + @GetMapping + fun listNotes(model: Model): String { + val userId = SecurityContextHolder.getContext().authentication.principal as String + val notes = noteRepository.findByOwnerId(ObjectId(userId)) + model.addAttribute("notes", notes) + model.addAttribute("noteForm", NoteForm()) + return "notes" + } + + @PostMapping + fun saveNote( + @Valid @ModelAttribute noteForm: NoteForm, + bindingResult: BindingResult, + model: Model + ): String { + if (bindingResult.hasErrors()) { + val userId = SecurityContextHolder.getContext().authentication.principal as String + val notes = noteRepository.findByOwnerId(ObjectId(userId)) + model.addAttribute("notes", notes) + return "notes" + } + + val userId = SecurityContextHolder.getContext().authentication.principal as String + val note = Note( + id = noteForm.id?.let { ObjectId(it) } ?: ObjectId.get(), + title = noteForm.title, + content = noteForm.content, + color = noteForm.color, + createdAt = Instant.now(), + ownerId = ObjectId(userId) + ) + noteRepository.save(note) + + return "redirect:/web/notes" + } + + @PostMapping("/{id}/delete") + fun deleteNote(@PathVariable id: String): String { + val note = noteRepository.findById(ObjectId(id)).orElseThrow { + IllegalArgumentException("Note not found") + } + val userId = SecurityContextHolder.getContext().authentication.principal as String + if (note.ownerId.toHexString() == userId) { + noteRepository.deleteById(ObjectId(id)) + } + return "redirect:/web/notes" + } +} diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/JwtAuthFilter.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/JwtAuthFilter.kt index c569fc5..2cb8eb3 100644 --- a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/JwtAuthFilter.kt +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/JwtAuthFilter.kt @@ -1,6 +1,7 @@ package com.plcoding.spring_boot_crash_course.security import jakarta.servlet.FilterChain +import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -18,14 +19,29 @@ class JwtAuthFilter( response: HttpServletResponse, filterChain: FilterChain ) { + var token: String? = null + + // First, try to get token from Authorization header val authHeader = request.getHeader("Authorization") if(authHeader != null && authHeader.startsWith("Bearer ")) { - if(jwtService.validateAccessToken(authHeader)) { - val userId = jwtService.getUserIdFromToken(authHeader) - val auth = UsernamePasswordAuthenticationToken(userId, null, emptyList()) - SecurityContextHolder.getContext().authentication = auth + token = authHeader + } else { + // If no header, try to get token from cookie + val cookies = request.cookies + if (cookies != null) { + val accessTokenCookie = cookies.find { it.name == "access_token" } + if (accessTokenCookie != null) { + token = accessTokenCookie.value + } } } + + // Validate token if found + if(token != null && jwtService.validateAccessToken(token)) { + val userId = jwtService.getUserIdFromToken(token) + val auth = UsernamePasswordAuthenticationToken(userId, null, emptyList()) + SecurityContextHolder.getContext().authentication = auth + } filterChain.doFilter(request, response) } diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt index 68e754f..8ba12ea 100644 --- a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt @@ -9,6 +9,7 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher @Configuration class SecurityConfig( @@ -18,14 +19,25 @@ class SecurityConfig( @Bean fun filterChain(httpSecurity: HttpSecurity): SecurityFilterChain { return httpSecurity - .csrf { csrf -> csrf.disable() } + .csrf { csrf -> + // Disable CSRF for REST API endpoints (they use JWT in headers) + // Enable CSRF for web form-based endpoints + csrf.ignoringRequestMatchers( + AntPathRequestMatcher("/auth/**"), + AntPathRequestMatcher("/notes/**") + ) + } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { auth -> auth - .requestMatchers("/") + .requestMatchers("/", "/web/home", "/web/login", "/web/register") + .permitAll() + .requestMatchers("/css/**", "/js/**", "/images/**") .permitAll() .requestMatchers("/auth/**") .permitAll() + .requestMatchers("/web/**") + .authenticated() .dispatcherTypeMatchers( DispatcherType.ERROR, DispatcherType.FORWARD @@ -38,6 +50,18 @@ class SecurityConfig( configurer .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) } + .formLogin { form -> + form + .loginPage("/web/login") + .permitAll() + } + .logout { logout -> + logout + .logoutUrl("/web/logout") + .logoutSuccessUrl("/web/login?logout") + .deleteCookies("access_token", "refresh_token") + .permitAll() + } .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) .build() } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..8c3f97e --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,357 @@ +/* Global Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; + color: #333; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +/* Header */ +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding: 20px; + background: white; + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.header h1 { + color: #667eea; +} + +/* Cards */ +.home-card, +.auth-card, +.note-form-card { + background: white; + padding: 40px; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + margin-bottom: 30px; +} + +.home-card { + text-align: center; +} + +.home-card h1 { + color: #667eea; + margin-bottom: 20px; +} + +.home-card p { + font-size: 18px; + color: #666; + margin-bottom: 30px; +} + +.features { + margin-top: 40px; + text-align: left; +} + +.features h2 { + color: #667eea; + margin-bottom: 20px; +} + +.features ul { + list-style: none; + padding: 0; +} + +.features li { + padding: 10px 0; + font-size: 16px; + color: #555; +} + +/* Auth Cards */ +.auth-card { + max-width: 500px; + margin: 50px auto; +} + +.auth-card h1 { + color: #667eea; + margin-bottom: 30px; + text-align: center; +} + +.auth-link { + text-align: center; + margin-top: 20px; + color: #666; +} + +.auth-link a { + color: #667eea; + text-decoration: none; +} + +.auth-link a:hover { + text-decoration: underline; +} + +/* Forms */ +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #333; +} + +.form-group input[type="text"], +.form-group input[type="email"], +.form-group input[type="password"], +.form-group textarea { + width: 100%; + padding: 12px; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 16px; + transition: border-color 0.3s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; +} + +.form-group textarea { + resize: vertical; + font-family: inherit; +} + +.help-text { + display: block; + margin-top: 5px; + font-size: 12px; + color: #666; +} + +/* Color Picker */ +.color-picker { + display: flex; + gap: 10px; +} + +.color-picker input[type="radio"] { + display: none; +} + +.color-option { + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + border: 3px solid transparent; + transition: border-color 0.3s, transform 0.2s; +} + +.color-picker input[type="radio"]:checked + .color-option { + border-color: #667eea; + transform: scale(1.1); +} + +/* Buttons */ +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + text-decoration: none; + display: inline-block; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-secondary { + background: #e0e0e0; + color: #333; +} + +.btn-secondary:hover { + background: #d0d0d0; +} + +.btn-small { + padding: 6px 12px; + font-size: 14px; +} + +.btn-edit { + background: #4CAF50; + color: white; +} + +.btn-edit:hover { + background: #45a049; +} + +.btn-delete { + background: #f44336; + color: white; +} + +.btn-delete:hover { + background: #da190b; +} + +.button-group { + display: flex; + gap: 10px; + margin-top: 20px; +} + +/* Messages */ +.error-message, +.success-message { + padding: 12px; + border-radius: 6px; + margin-bottom: 20px; +} + +.error-message { + background: #ffebee; + color: #c62828; + border: 1px solid #ef5350; +} + +.success-message { + background: #e8f5e9; + color: #2e7d32; + border: 1px solid #66bb6a; +} + +.error { + color: #c62828; + font-size: 14px; + display: block; + margin-top: 5px; +} + +/* Notes Grid */ +.notes-list { + background: white; + padding: 30px; + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.notes-list h2 { + color: #667eea; + margin-bottom: 20px; +} + +.empty-message { + text-align: center; + padding: 40px; + color: #999; + font-size: 18px; +} + +.notes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.note-card { + padding: 20px; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transition: transform 0.2s, box-shadow 0.2s; + position: relative; +} + +.note-card:hover { + transform: translateY(-4px); + box-shadow: 0 4px 16px rgba(0,0,0,0.15); +} + +.note-card h3 { + margin-bottom: 10px; + color: #333; + word-wrap: break-word; +} + +.note-card p { + color: #555; + margin-bottom: 15px; + word-wrap: break-word; + white-space: pre-wrap; +} + +.note-meta { + font-size: 12px; + color: #666; + margin-bottom: 15px; +} + +.note-actions { + display: flex; + gap: 10px; +} + +.note-form-card h2 { + color: #667eea; + margin-bottom: 20px; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .container { + padding: 10px; + } + + .notes-grid { + grid-template-columns: 1fr; + } + + .header { + flex-direction: column; + gap: 15px; + } + + .button-group { + flex-direction: column; + } + + .btn { + width: 100%; + } +} diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js new file mode 100644 index 0000000..dc0e807 --- /dev/null +++ b/src/main/resources/static/js/auth.js @@ -0,0 +1,55 @@ +// Auth.js - Client-side enhancements for authentication pages + +document.addEventListener('DOMContentLoaded', function() { + // Add form validation feedback + const forms = document.querySelectorAll('form'); + + forms.forEach(form => { + form.addEventListener('submit', function(e) { + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Processing...'; + + // Re-enable after 3 seconds in case of network issues + setTimeout(() => { + submitBtn.disabled = false; + submitBtn.textContent = submitBtn.dataset.originalText || submitBtn.textContent.replace('Processing...', 'Submit'); + }, 3000); + } + }); + + // Store original button text + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn && !submitBtn.dataset.originalText) { + submitBtn.dataset.originalText = submitBtn.textContent; + } + }); + + // Password strength indicator for register page + const passwordInput = document.getElementById('password'); + if (passwordInput && window.location.pathname.includes('register')) { + passwordInput.addEventListener('input', function() { + const password = this.value; + const strength = calculatePasswordStrength(password); + updatePasswordStrengthUI(strength); + }); + } +}); + +function calculatePasswordStrength(password) { + let strength = 0; + + if (password.length >= 9) strength += 25; + if (/[a-z]/.test(password)) strength += 25; + if (/[A-Z]/.test(password)) strength += 25; + if (/\d/.test(password)) strength += 25; + + return strength; +} + +function updatePasswordStrengthUI(strength) { + // Optional: Add a password strength indicator + // This is a placeholder for future enhancement + console.log('Password strength:', strength); +} diff --git a/src/main/resources/static/js/notes.js b/src/main/resources/static/js/notes.js new file mode 100644 index 0000000..3d3ed85 --- /dev/null +++ b/src/main/resources/static/js/notes.js @@ -0,0 +1,53 @@ +// Notes.js - Client-side enhancements for the notes page + +function editNote(id, title, content, color) { + // Fill the form with the note's data + document.getElementById('noteId').value = id; + document.getElementById('title').value = decodeHTML(title); + document.getElementById('content').value = decodeHTML(content); + + // Select the appropriate color radio button + const colorInputs = document.querySelectorAll('input[name="color"]'); + colorInputs.forEach(input => { + if (input.value === color.toString()) { + input.checked = true; + } + }); + + // Update form title and button text + document.getElementById('form-title').textContent = 'Edit Note'; + document.getElementById('submitBtn').textContent = 'Update Note'; + + // Show cancel button + document.getElementById('cancelBtn').style.display = 'inline-block'; + + // Scroll to form + document.querySelector('.note-form-card').scrollIntoView({ behavior: 'smooth' }); +} + +function resetForm() { + // Reset the form + document.getElementById('noteForm').reset(); + document.getElementById('noteId').value = ''; + + // Reset form title and button text + document.getElementById('form-title').textContent = 'Create New Note'; + document.getElementById('submitBtn').textContent = 'Create Note'; + + // Hide cancel button + document.getElementById('cancelBtn').style.display = 'none'; +} + +function decodeHTML(html) { + const txt = document.createElement('textarea'); + txt.innerHTML = html; + return txt.value; +} + +// Initialize - hide cancel button on page load +document.addEventListener('DOMContentLoaded', function() { + const cancelBtn = document.getElementById('cancelBtn'); + if (cancelBtn && !document.getElementById('noteId').value) { + cancelBtn.style.display = 'none'; + } +}); diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..3b05acf --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,32 @@ + + + + + + Spring Boot Notes - Home + + + +
+
+

Welcome to Spring Boot Notes

+

Manage your notes securely with our application.

+ +
+ Login + Register +
+ +
+

Features

+
    +
  • 🔐 Secure authentication with JWT
  • +
  • 📝 Create, read, update, and delete notes
  • +
  • 🎨 Colorful note cards for better organization
  • +
  • 🔄 RESTful API available for integration
  • +
+
+
+
+ + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..a6815d8 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,53 @@ + + + + + + Login - Spring Boot Notes + + + +
+
+

Login

+ +
+ Registration successful! Please log in. +
+ +
+ You have been logged out successfully. +
+ +
+ +
+ + +
+ + + +
+ +
+ + + +
+ + +
+ + + + +
+
+ + + diff --git a/src/main/resources/templates/notes.html b/src/main/resources/templates/notes.html new file mode 100644 index 0000000..4806c64 --- /dev/null +++ b/src/main/resources/templates/notes.html @@ -0,0 +1,95 @@ + + + + + + My Notes - Spring Boot Notes + + + +
+
+

My Notes

+
+ + +
+
+ +
+

Create New Note

+ +
+ + + +
+ + + +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + +
+
+ +
+ + +
+
+
+ +
+

Your Notes

+
+ No notes yet. Create your first note above! +
+ +
+
+

+

+
+ +
+
+ +
+ + +
+
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/register.html b/src/main/resources/templates/register.html new file mode 100644 index 0000000..6f9eee6 --- /dev/null +++ b/src/main/resources/templates/register.html @@ -0,0 +1,49 @@ + + + + + + Register - Spring Boot Notes + + + +
+
+

Register

+ +
+ +
+ + +
+ + + +
+ +
+ + + + + Password must be at least 9 characters long and contain at least one digit, + uppercase and lowercase character. + +
+ + +
+ + + + +
+
+ + + From a55527da8b32798804399b4aeaaebe7ed1c54494 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:43:18 +0000 Subject: [PATCH 4/5] Fix Thymeleaf template issues and complete MPA implementation Co-authored-by: sebaslagu <75394396+sebaslagu@users.noreply.github.com> --- .../controllers/web/WebHomeController.kt | 2 +- .../security/SecurityConfig.kt | 14 +------------- src/main/resources/static/js/notes.js | 12 ++++++++++-- src/main/resources/templates/login.html | 2 -- src/main/resources/templates/notes.html | 10 ++++------ src/main/resources/templates/register.html | 2 -- 6 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt index 4f8c868..293376c 100644 --- a/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/controllers/web/WebHomeController.kt @@ -6,7 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping @Controller class WebHomeController { - @GetMapping("/", "/web/home") + @GetMapping("/web/home") fun home(): String { return "home" } diff --git a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt index 8ba12ea..9c65605 100644 --- a/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt +++ b/src/main/kotlin/com/plcoding/spring_boot_crash_course/security/SecurityConfig.kt @@ -19,14 +19,7 @@ class SecurityConfig( @Bean fun filterChain(httpSecurity: HttpSecurity): SecurityFilterChain { return httpSecurity - .csrf { csrf -> - // Disable CSRF for REST API endpoints (they use JWT in headers) - // Enable CSRF for web form-based endpoints - csrf.ignoringRequestMatchers( - AntPathRequestMatcher("/auth/**"), - AntPathRequestMatcher("/notes/**") - ) - } + .csrf { csrf -> csrf.disable() } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { auth -> auth @@ -50,11 +43,6 @@ class SecurityConfig( configurer .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) } - .formLogin { form -> - form - .loginPage("/web/login") - .permitAll() - } .logout { logout -> logout .logoutUrl("/web/logout") diff --git a/src/main/resources/static/js/notes.js b/src/main/resources/static/js/notes.js index 3d3ed85..8eb2955 100644 --- a/src/main/resources/static/js/notes.js +++ b/src/main/resources/static/js/notes.js @@ -1,10 +1,18 @@ // Notes.js - Client-side enhancements for the notes page +function editNoteFromData(button) { + const id = button.getAttribute('data-note-id'); + const title = button.getAttribute('data-note-title'); + const content = button.getAttribute('data-note-content'); + const color = button.getAttribute('data-note-color'); + editNote(id, title, content, color); +} + function editNote(id, title, content, color) { // Fill the form with the note's data document.getElementById('noteId').value = id; - document.getElementById('title').value = decodeHTML(title); - document.getElementById('content').value = decodeHTML(content); + document.getElementById('title').value = title; + document.getElementById('content').value = content; // Select the appropriate color radio button const colorInputs = document.querySelectorAll('input[name="color"]'); diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index a6815d8..ff572d9 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -22,8 +22,6 @@

Login

- -
diff --git a/src/main/resources/templates/notes.html b/src/main/resources/templates/notes.html index 4806c64..d878b97 100644 --- a/src/main/resources/templates/notes.html +++ b/src/main/resources/templates/notes.html @@ -11,7 +11,6 @@

My Notes

-
@@ -20,7 +19,6 @@

My Notes

Create New Note

-
@@ -68,20 +66,20 @@

Your Notes

-
+

- -
diff --git a/src/main/resources/templates/register.html b/src/main/resources/templates/register.html index 6f9eee6..61f012f 100644 --- a/src/main/resources/templates/register.html +++ b/src/main/resources/templates/register.html @@ -14,8 +14,6 @@

Register

- -
From 20d6b1c042b9ff78aa9d59d4e5debaefa75b1a78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 01:45:32 +0000 Subject: [PATCH 5/5] Add comprehensive README documentation Co-authored-by: sebaslagu <75394396+sebaslagu@users.noreply.github.com> --- README.md | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5a5a92 --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# Spring Boot Crash Course - Notes Application + +A full-stack notes management application built with Kotlin, Spring Boot, MongoDB, and JWT authentication. Features both a RESTful API and a server-side rendered web interface (MPA). + +## Features + +### Core Functionality +- 📝 **CRUD Operations**: Create, Read, Update, and Delete notes +- 🔐 **JWT Authentication**: Secure token-based authentication with access and refresh tokens +- 🎨 **Colorful Notes**: 5 predefined color options for better organization +- 👤 **User Management**: Secure registration and login system +- 🔄 **Dual Interface**: RESTful API for programmatic access and Web UI for browser access + +### Technical Features +- **Backend**: Kotlin + Spring Boot 3.4.3 +- **Database**: MongoDB with Spring Data +- **Security**: JWT tokens with 15-minute access tokens and 30-day refresh tokens +- **View Layer**: Thymeleaf templates for server-side rendering +- **Cookie Management**: HttpOnly cookies for secure token storage in web interface +- **Validation**: Jakarta Bean Validation with custom error handling + +## Architecture + +### REST API Endpoints +- `POST /auth/register` - Register a new user +- `POST /auth/login` - Login and receive JWT tokens +- `POST /auth/refresh` - Refresh access token +- `GET /notes` - Get all notes for authenticated user +- `POST /notes` - Create or update a note +- `DELETE /notes/{id}` - Delete a note + +### Web Interface Routes +- `GET /web/home` - Landing page +- `GET /web/register` - Registration page +- `POST /web/register` - Process registration +- `GET /web/login` - Login page +- `POST /web/login` - Process login (sets JWT cookies) +- `GET /web/notes` - Notes management page (protected) +- `POST /web/notes` - Create/update note via form +- `POST /web/notes/{id}/delete` - Delete note +- `POST /web/logout` - Logout (clears cookies) + +## Getting Started + +### Prerequisites +- Java 17 or higher +- MongoDB instance (local or Atlas) +- Gradle (wrapper included) + +### Environment Variables +```bash +MONGODB_CONNECTION_STRING=mongodb://localhost:27017/spring_crash_course +JWT_SECRET_BASE64= +``` + +### Running the Application + +1. **Clone the repository** +```bash +git clone https://github.com/sebaslagu/SpringBootCrashCourse.git +cd SpringBootCrashCourse +``` + +2. **Set environment variables** +```bash +export MONGODB_CONNECTION_STRING="mongodb://localhost:27017/spring_crash_course" +export JWT_SECRET_BASE64="" +``` + +3. **Run with Gradle** +```bash +./gradlew bootRun +``` + +4. **Access the application** +- Web Interface: http://localhost:8085/web/home +- API: http://localhost:8085/ + +### Docker Setup (MongoDB) +```bash +docker run -d --name mongodb-notes -p 27017:27017 mongo:latest +``` + +## Project Structure + +``` +src/main/ +├── kotlin/com/plcoding/spring_boot_crash_course/ +│ ├── controllers/ +│ │ ├── AuthController.kt # REST API authentication +│ │ ├── NoteController.kt # REST API notes CRUD +│ │ ├── StatusController.kt # Health check endpoint +│ │ └── web/ +│ │ ├── WebHomeController.kt # Web home page +│ │ ├── WebAuthController.kt # Web auth pages +│ │ └── WebNoteController.kt # Web notes pages +│ ├── database/ +│ │ ├── model/ +│ │ │ ├── User.kt +│ │ │ ├── Note.kt +│ │ │ └── RefreshToken.kt +│ │ └── repository/ # MongoDB repositories +│ └── security/ +│ ├── SecurityConfig.kt # Spring Security configuration +│ ├── JwtAuthFilter.kt # JWT validation filter +│ ├── JwtService.kt # JWT token management +│ ├── AuthService.kt # Authentication service +│ └── HashEncoder.kt # Password hashing +└── resources/ + ├── templates/ # Thymeleaf templates + │ ├── home.html + │ ├── login.html + │ ├── register.html + │ └── notes.html + ├── static/ + │ ├── css/ + │ │ └── styles.css + │ └── js/ + │ ├── notes.js + │ └── auth.js + └── application.properties +``` + +## Security + +### Authentication Flow + +**API Authentication:** +1. Client calls `/auth/login` with credentials +2. Server returns `accessToken` and `refreshToken` in JSON response +3. Client includes `Authorization: Bearer ` header in subsequent requests +4. When access token expires, client calls `/auth/refresh` with refresh token + +**Web Authentication:** +1. User submits login form to `/web/login` +2. Server validates credentials and generates tokens +3. Tokens are stored in HttpOnly cookies: + - `access_token`: 15-minute expiry + - `refresh_token`: 30-day expiry +4. Cookies are automatically sent with subsequent requests +5. `JwtAuthFilter` validates tokens from either cookies or Authorization header + +### Password Requirements +- Minimum 9 characters +- At least one uppercase letter +- At least one lowercase letter +- At least one digit + +### Security Notes +- **CSRF Protection**: Currently disabled for simplicity. In production, consider enabling CSRF protection for web forms while exempting API endpoints. +- **Cookie Security**: Cookies use `HttpOnly` flag to prevent JavaScript access. In production, also set `Secure` flag to require HTTPS. +- **Token Storage**: Refresh tokens are hashed before storage in MongoDB. +- **Stateless Sessions**: Application uses stateless JWT authentication; no server-side session storage. + +## API Usage Examples + +### Register a User +```bash +curl -X POST http://localhost:8085/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"Password123"}' +``` + +### Login +```bash +curl -X POST http://localhost:8085/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"Password123"}' +``` + +Response: +```json +{ + "accessToken": "eyJhbGc...", + "refreshToken": "eyJhbGc..." +} +``` + +### Create a Note +```bash +curl -X POST http://localhost:8085/notes \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -d '{ + "title":"Shopping List", + "content":"Buy milk and eggs", + "color":4293848814 + }' +``` + +### Get All Notes +```bash +curl http://localhost:8085/notes \ + -H "Authorization: Bearer " +``` + +## Development + +### Building +```bash +./gradlew build +``` + +### Running Tests +```bash +./gradlew test +``` + +### Code Style +The project follows Kotlin coding conventions with Spring Boot best practices. + +## Technologies Used + +- **Kotlin** 1.9.25 +- **Spring Boot** 3.4.3 +- **Spring Security** with JWT +- **Spring Data MongoDB** +- **Thymeleaf** template engine +- **MongoDB** for data persistence +- **JJWT** 0.12.6 for JWT handling +- **Gradle** for build management + +## Color Options + +Notes can be assigned one of five colors: +- 🟡 Peach (`4294951115`) +- 🩷 Pink (`4293848814`) +- 🟢 Green (`4289374890`) +- 🟣 Purple (`4292149695`) +- ⚪ White (`4278255615`) + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is open source and available for educational purposes. + +## Author + +Based on the Spring Boot Crash Course by Philipp Lackner. +MPA layer implementation by GitHub Copilot. + +## Acknowledgments + +- Spring Boot team for the excellent framework +- MongoDB for the database +- Thymeleaf for the template engine