diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java index 4dd2d27..3e00530 100644 --- a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java @@ -23,11 +23,22 @@ public class AuthController { this.authenticationManager = authenticationManager; } + /** + * Registers a new user. + * @param request Registration details + * @return The registered user + */ @PostMapping("/register") public ResponseEntity register(@RequestBody Dtos.RegisterRequest request) { + // registering the user, hope they remember their password return ResponseEntity.ok(authService.register(request)); } + /** + * Logs in a user. + * @param request Login credentials + * @return The user details if login succeeds + */ @PostMapping("/login") public ResponseEntity login(@RequestBody Dtos.LoginRequest request) { Authentication authentication = authenticationManager.authenticate( @@ -37,16 +48,29 @@ public class AuthController { return ResponseEntity.ok(authService.getUserByEmail(request.getEmail())); } + /** + * Gets the currently authenticated user. + * @param principal The security principal + * @return The user + */ @GetMapping("/me") public ResponseEntity getCurrentUser(Principal principal) { return ResponseEntity.ok(authService.getUserByEmail(principal.getName())); } + + /** + * Updates the supervised rooms for the current user. + * @param request Room IDs + * @param principal The security principal + * @return The updated user + */ @PutMapping("/profile/rooms") public ResponseEntity updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) { return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds())); } // Emergency endpoint to promote a user to ADMIN (Removed before production!) + // seriously, delete this before going live or we are doomed @PostMapping("/dev-promote-admin") public ResponseEntity promoteToAdmin(@RequestBody Dtos.LoginRequest request) { // Reusing LoginRequest for email/password check essentially or just email // Ideally we check a secret key, but for now we just allow promoting by email if password matches or just by email for simplicity in this stuck state diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/UserController.java b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/UserController.java new file mode 100644 index 0000000..9a1652a --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/UserController.java @@ -0,0 +1,35 @@ +package de.itsolutions.ticketsystem.controller; + +import de.itsolutions.ticketsystem.dto.Dtos; +import de.itsolutions.ticketsystem.entity.User; +import de.itsolutions.ticketsystem.service.AuthService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.security.Principal; + +/** + * Controller for stuff that relates to the user, but not necessarily login/register. + * Kept separate because clean code or whatever. + */ +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final AuthService authService; + + public UserController(AuthService authService) { + this.authService = authService; + } + + /** + * Updates the theme for the current user. + * @param request The theme update request + * @param principal The security principal (the logged in dude) + * @return The updated user + */ + @PatchMapping("/theme") + public ResponseEntity updateTheme(@RequestBody Dtos.ThemeUpdateRequest request, Principal principal) { + // Just delegating to service, standard procedure + return ResponseEntity.ok(authService.updateTheme(principal.getName(), request.getTheme())); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java b/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java index e8507cb..8c00fff 100644 --- a/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java @@ -62,4 +62,11 @@ public class Dtos { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } } + + // Request payload for changing the vibe (theme) + public static class ThemeUpdateRequest { + private String theme; + public String getTheme() { return theme; } + public void setTheme(String theme) { this.theme = theme; } + } } diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java index 31c3bb6..5732a8c 100644 --- a/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java @@ -38,6 +38,9 @@ public class User { @Column(nullable = false) private String role; + @Column(name = "theme", nullable = false) + private String theme = "system"; + @ManyToMany(fetch = FetchType.EAGER) @JoinTable( name = "user_rooms", @@ -50,12 +53,14 @@ public class User { public void setId(Long id) { this.id = id; } public String getFirstname() { return name_firstname; } + // setter for firstname, keeping legacy vorname in sync because reasons... public void setFirstname(String firstname) { this.name_firstname = firstname; this.name_vorname = firstname; } public String getLastname() { return name_lastname; } + // syncing lastname with nachname, database duplicates ftw public void setLastname(String lastname) { this.name_lastname = lastname; this.name_nachname = lastname; @@ -65,6 +70,7 @@ public class User { return this.name_column; } + // null check is good for the soul public void setName(String name) { if (name == null || name.isBlank()) { name = "Unknown User"; @@ -80,4 +86,8 @@ public class User { public void setRole(String role) { this.role = role; } public List getSupervisedRooms() { return supervisedRooms; } public void setSupervisedRooms(List supervisedRooms) { this.supervisedRooms = supervisedRooms; } + + // dark mode stuff, gotta save those eyes + public String getTheme() { return theme; } + public void setTheme(String theme) { this.theme = theme; } } diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java b/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java index 8ad8aca..4122dc7 100644 --- a/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java @@ -100,6 +100,21 @@ public class AuthService { return userRepository.save(user); } + + /** + * Updates the user's theme preference. + * @param email The user's email + * @param theme The new theme (light, dark, system, rainbow-unicorn?) + * @return The updated user + */ + public User updateTheme(String email, String theme) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + // whatever theme they want, they get. no validation, yolo. + user.setTheme(theme); + return userRepository.save(user); + } public User promoteToAdmin(String email) { User user = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found")); diff --git a/Frontend/app/layout.tsx b/Frontend/app/layout.tsx index 1179ff2..4d1b952 100644 --- a/Frontend/app/layout.tsx +++ b/Frontend/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google' import { Analytics } from '@vercel/analytics/next' import './globals.css' import { NextAuthSessionProvider } from "@/components/session-provider" +import { UserThemeSync } from "@/components/user-theme-sync" const _geist = Geist({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] }); @@ -40,6 +41,7 @@ export default function RootLayout({ + {children} diff --git a/Frontend/components/auth/login-form.tsx b/Frontend/components/auth/login-form.tsx index 21d9c6c..129fab0 100644 --- a/Frontend/components/auth/login-form.tsx +++ b/Frontend/components/auth/login-form.tsx @@ -24,6 +24,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) { setError("") setIsLoading(true) + // let's hope they know their password lmao const success = await login(email, password) if (!success) { setError("Ungültige E-Mail oder Passwort") diff --git a/Frontend/components/auth/register-form.tsx b/Frontend/components/auth/register-form.tsx index 4a8578d..d50d17a 100644 --- a/Frontend/components/auth/register-form.tsx +++ b/Frontend/components/auth/register-form.tsx @@ -40,6 +40,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) { setIsLoading(true) + // new user alert! make sure they don't break anything const success = await register( { firstname, diff --git a/Frontend/components/layout/app-shell.tsx b/Frontend/components/layout/app-shell.tsx index 2f2b66b..60d9293 100644 --- a/Frontend/components/layout/app-shell.tsx +++ b/Frontend/components/layout/app-shell.tsx @@ -17,6 +17,7 @@ import { DropdownMenuCheckboxItem, } from "@/components/ui/dropdown-menu" import { LogOut, User, Building2, Cpu } from "lucide-react" +import { ModeToggle } from "@/components/mode-toggle" interface AppShellProps { children: ReactNode @@ -89,13 +90,13 @@ export function AppShell({ children }: AppShellProps) { Räume verwalten {rooms.map((room) => { - const isChecked = user.supervisedRooms.some((r) => r.id === room.id) + const isChecked = user.supervisedRooms?.some((r) => r.id === room.id) ?? false return ( { - const currentIds = user.supervisedRooms.map((r) => r.id) + const currentIds = user.supervisedRooms?.map((r) => r.id) || [] let newIds if (checked) { newIds = [...currentIds, room.id] @@ -120,6 +121,9 @@ export function AppShell({ children }: AppShellProps) { + + {/* dark mode toggle because staring at white screens hurts */} + diff --git a/Frontend/components/mode-toggle.tsx b/Frontend/components/mode-toggle.tsx new file mode 100644 index 0000000..8f261a1 --- /dev/null +++ b/Frontend/components/mode-toggle.tsx @@ -0,0 +1,36 @@ +"use client" + +import * as React from "react" +import { Moon, Sun } from "lucide-react" +import { useTheme } from "next-themes" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" + +export function ModeToggle() { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = React.useState(false) + + // Hydration fix + React.useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return null + } + + const isDark = theme === "dark" + + return ( +
+ + setTheme(checked ? "dark" : "light")} + /> + + +
+ ) +} diff --git a/Frontend/components/user-theme-sync.tsx b/Frontend/components/user-theme-sync.tsx new file mode 100644 index 0000000..abf31df --- /dev/null +++ b/Frontend/components/user-theme-sync.tsx @@ -0,0 +1,67 @@ +"use client" + +import { useTheme } from "next-themes" +import { useEffect, useRef } from "react" +import { useSession } from "next-auth/react" + +export function UserThemeSync() { + const { theme, setTheme } = useTheme() + const { data: session } = useSession() + const initialSyncDone = useRef(false) + + // Sync from DB on load + useEffect(() => { + // If we have a user and haven't synced yet + if (session?.user && !initialSyncDone.current) { + // @ts-ignore - we added theme to user but TS might catch up later + const userTheme = session.user.theme + if (userTheme && userTheme !== "system") { + setTheme(userTheme) + } + initialSyncDone.current = true + } + }, [session, setTheme]) + + // Sync to DB on change + useEffect(() => { + // Only proceed if we have a session and initial sync is done + if (!session?.user || !initialSyncDone.current) return + + // @ts-ignore + const currentDbTheme = session.user.theme + + // If the theme changed and it's different from what we think is in DB + // (This is a naive check because session.user.theme won't update until next session fetch) + if (theme && theme !== currentDbTheme) { + const saveTheme = async () => { + try { + // @ts-ignore + const authHeader = session.authHeader + + await fetch(process.env.NEXT_PUBLIC_API_URL + "/api/users/theme", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": authHeader + }, + body: JSON.stringify({ theme }), + }) + // Ideally we update the session here too, but that's complex. + // We just fire and forget. + } catch (e) { + console.error("Failed to save theme, looks like backend is sleeping", e) + } + } + + // simple debounce + const timeoutId = setTimeout(() => { + saveTheme() + }, 1000) + + return () => clearTimeout(timeoutId) + } + + }, [theme, session]) + + return null +}