This commit is contained in:
Hymmel 2026-01-22 09:41:05 +01:00
parent 4151dd89ed
commit 92d569b9cd
11 changed files with 204 additions and 2 deletions

View file

@ -23,11 +23,22 @@ public class AuthController {
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
} }
/**
* Registers a new user.
* @param request Registration details
* @return The registered user
*/
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<User> register(@RequestBody Dtos.RegisterRequest request) { public ResponseEntity<User> register(@RequestBody Dtos.RegisterRequest request) {
// registering the user, hope they remember their password
return ResponseEntity.ok(authService.register(request)); return ResponseEntity.ok(authService.register(request));
} }
/**
* Logs in a user.
* @param request Login credentials
* @return The user details if login succeeds
*/
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<User> login(@RequestBody Dtos.LoginRequest request) { public ResponseEntity<User> login(@RequestBody Dtos.LoginRequest request) {
Authentication authentication = authenticationManager.authenticate( Authentication authentication = authenticationManager.authenticate(
@ -37,16 +48,29 @@ public class AuthController {
return ResponseEntity.ok(authService.getUserByEmail(request.getEmail())); return ResponseEntity.ok(authService.getUserByEmail(request.getEmail()));
} }
/**
* Gets the currently authenticated user.
* @param principal The security principal
* @return The user
*/
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<User> getCurrentUser(Principal principal) { public ResponseEntity<User> getCurrentUser(Principal principal) {
return ResponseEntity.ok(authService.getUserByEmail(principal.getName())); 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") @PutMapping("/profile/rooms")
public ResponseEntity<User> updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) { public ResponseEntity<User> updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) {
return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds())); return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds()));
} }
// Emergency endpoint to promote a user to ADMIN (Removed before production!) // 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") @PostMapping("/dev-promote-admin")
public ResponseEntity<User> promoteToAdmin(@RequestBody Dtos.LoginRequest request) { // Reusing LoginRequest for email/password check essentially or just email public ResponseEntity<User> 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 // 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

View file

@ -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<User> updateTheme(@RequestBody Dtos.ThemeUpdateRequest request, Principal principal) {
// Just delegating to service, standard procedure
return ResponseEntity.ok(authService.updateTheme(principal.getName(), request.getTheme()));
}
}

View file

@ -62,4 +62,11 @@ public class Dtos {
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = 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; }
}
} }

View file

@ -38,6 +38,9 @@ public class User {
@Column(nullable = false) @Column(nullable = false)
private String role; private String role;
@Column(name = "theme", nullable = false)
private String theme = "system";
@ManyToMany(fetch = FetchType.EAGER) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable( @JoinTable(
name = "user_rooms", name = "user_rooms",
@ -50,12 +53,14 @@ public class User {
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
public String getFirstname() { return name_firstname; } public String getFirstname() { return name_firstname; }
// setter for firstname, keeping legacy vorname in sync because reasons...
public void setFirstname(String firstname) { public void setFirstname(String firstname) {
this.name_firstname = firstname; this.name_firstname = firstname;
this.name_vorname = firstname; this.name_vorname = firstname;
} }
public String getLastname() { return name_lastname; } public String getLastname() { return name_lastname; }
// syncing lastname with nachname, database duplicates ftw
public void setLastname(String lastname) { public void setLastname(String lastname) {
this.name_lastname = lastname; this.name_lastname = lastname;
this.name_nachname = lastname; this.name_nachname = lastname;
@ -65,6 +70,7 @@ public class User {
return this.name_column; return this.name_column;
} }
// null check is good for the soul
public void setName(String name) { public void setName(String name) {
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
name = "Unknown User"; name = "Unknown User";
@ -80,4 +86,8 @@ public class User {
public void setRole(String role) { this.role = role; } public void setRole(String role) { this.role = role; }
public List<Room> getSupervisedRooms() { return supervisedRooms; } public List<Room> getSupervisedRooms() { return supervisedRooms; }
public void setSupervisedRooms(List<Room> supervisedRooms) { this.supervisedRooms = supervisedRooms; } public void setSupervisedRooms(List<Room> supervisedRooms) { this.supervisedRooms = supervisedRooms; }
// dark mode stuff, gotta save those eyes
public String getTheme() { return theme; }
public void setTheme(String theme) { this.theme = theme; }
} }

View file

@ -100,6 +100,21 @@ public class AuthService {
return userRepository.save(user); 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) { public User promoteToAdmin(String email) {
User user = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found")); User user = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));

View file

@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next' import { Analytics } from '@vercel/analytics/next'
import './globals.css' import './globals.css'
import { NextAuthSessionProvider } from "@/components/session-provider" import { NextAuthSessionProvider } from "@/components/session-provider"
import { UserThemeSync } from "@/components/user-theme-sync"
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] });
@ -40,6 +41,7 @@ export default function RootLayout({
<html lang="en"> <html lang="en">
<body className={`font-sans antialiased`}> <body className={`font-sans antialiased`}>
<NextAuthSessionProvider> <NextAuthSessionProvider>
<UserThemeSync />
{children} {children}
<Analytics /> <Analytics />
</NextAuthSessionProvider> </NextAuthSessionProvider>

View file

@ -24,6 +24,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
setError("") setError("")
setIsLoading(true) setIsLoading(true)
// let's hope they know their password lmao
const success = await login(email, password) const success = await login(email, password)
if (!success) { if (!success) {
setError("Ungültige E-Mail oder Passwort") setError("Ungültige E-Mail oder Passwort")

View file

@ -40,6 +40,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
setIsLoading(true) setIsLoading(true)
// new user alert! make sure they don't break anything
const success = await register( const success = await register(
{ {
firstname, firstname,

View file

@ -17,6 +17,7 @@ import {
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { LogOut, User, Building2, Cpu } from "lucide-react" import { LogOut, User, Building2, Cpu } from "lucide-react"
import { ModeToggle } from "@/components/mode-toggle"
interface AppShellProps { interface AppShellProps {
children: ReactNode children: ReactNode
@ -89,13 +90,13 @@ export function AppShell({ children }: AppShellProps) {
<DropdownMenuSubContent className="max-h-60 overflow-y-auto"> <DropdownMenuSubContent className="max-h-60 overflow-y-auto">
<DropdownMenuLabel>Räume verwalten</DropdownMenuLabel> <DropdownMenuLabel>Räume verwalten</DropdownMenuLabel>
{rooms.map((room) => { {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 ( return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={room.id} key={room.id}
checked={isChecked} checked={isChecked}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
const currentIds = user.supervisedRooms.map((r) => r.id) const currentIds = user.supervisedRooms?.map((r) => r.id) || []
let newIds let newIds
if (checked) { if (checked) {
newIds = [...currentIds, room.id] newIds = [...currentIds, room.id]
@ -120,6 +121,9 @@ export function AppShell({ children }: AppShellProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* dark mode toggle because staring at white screens hurts */}
<ModeToggle />
</div> </div>
</div> </div>
</header> </header>

View file

@ -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 (
<div className="flex items-center space-x-2">
<Sun className="h-4 w-4" />
<Switch
id="dark-mode"
checked={isDark}
onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
/>
<Moon className="h-4 w-4" />
<Label htmlFor="dark-mode" className="sr-only">Toggle theme</Label>
</div>
)
}

View file

@ -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
}