diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5923390..22dffc4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,36 +1,47 @@ -import { BrowserRouter as Router, Routes, Route } from "react-router-dom" +import {BrowserRouter as Router, Route, Routes} from "react-router-dom" import RecipeDetailPage from "./components/recipes/RecipeDetailPage" import RecipeEditPage from "./components/recipes/RecipeEditPage" import RecipeListPage from "./components/recipes/RecipeListPage" -import { getLoginUrl, getRecipeAddUrlDefinition, getRecipeDetailsUrlDefinition, getRecipeEditUrlDefinition, getRecipeListUrlDefinition, getRootUrlDefinition } from "./routes" +import { + getLoginUrlDefinition, + getRecipeAddUrlDefinition, + getRecipeDetailsUrlDefinition, + getRecipeEditUrlDefinition, + getRecipeListUrlDefinition, + getUserUrlDefinition +} from "./routes" import "./App.css" import LoginPage from "./components/LoginPage" +import UserManagementPage from "./components/users/UserManagementPage.tsx"; /** * Main application component. * Defines routes for the recipe list, detail view, and edit form. */ function App() { - return ( - - - {/* Login page */} - }/> - {/* Home page: list of recipes */} - } /> + return ( + + + {/* Login page */} + }/> - {/* Detail page: shows one recipe */} - } /> + {/* Home page: list of recipes */} + }/> - {/* Edit page: form to edit a recipe */} - } /> + {/* Detail page: shows one recipe */} + }/> - {/* Add page: form to add a recipe */} - } /> - - - ) + {/* Edit page: form to edit a recipe */} + }/> + + {/* Add page: form to add a recipe */} + }/> + {/*User management */} + }/> + + + ) } export default App \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts new file mode 100644 index 0000000..a7185f3 --- /dev/null +++ b/frontend/src/api/apiClient.ts @@ -0,0 +1,97 @@ +/** + * A centralized API client that wraps around the native fetch API. + * It automatically handles authentication headers, JSON parsing, and error handling. + * + * This client is used across the app for interacting with backend endpoints. + * + * @todo modify all endpoints to use api client and remove old utils + */ + +const API_BASE_URL = import.meta.env.VITE_API_BASE + +/** + * Helper: Reads the JWT token from localStorage (if available) + */ +function getAuthToken(): string | null { + try { + const sessionData = localStorage.getItem("session"); + if (!sessionData) return null; + + const parsed = JSON.parse(sessionData); + return parsed.token ?? null; + } catch (err) { + console.error("Failed to parse token from localStorage:", err); + return null; + } +} + +/** + * Custom error class for API responses. + */ +export class ApiError extends Error { + public status: number; + public details?: string; + + constructor(message: string, status: number, details?: string) { + super(message); + this.status = status; + this.details = details; + } +} + +/** + * Wrapper for making API requests. + * Handles JSON encoding/decoding and authorization headers automatically. + * + * @param endpoint - API endpoint (e.g. "user/me") + * @param options - fetch configuration (method, headers, body) + * @returns parsed JSON response + */ +export async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise { + const token = getAuthToken(); + + const headers: HeadersInit = { + "Content-Type": "application/json", + ...(token ? {Authorization: `Bearer ${token}`} : {}), + ...options.headers, + }; + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers, + }); + + // Handle non-OK responses gracefully + if (!response.ok) { + let details; + try { + details = await response.json(); + } catch { + details = await response.text(); + } + throw new ApiError(`Request to ${endpoint} failed`, response.status, details); + } + + // Handle empty response bodies + if (response.status === 204) { + return {} as T; + } + + return response.json() as Promise; +} + +/** + * Shorthand helpers for convenience. + */ +export const apiClient = { + get: (endpoint: string) => apiRequest(endpoint, {method: "GET"}), + post: (endpoint: string, body: object) => + apiRequest(endpoint, {method: "POST", body: JSON.stringify(body)}), + put: (endpoint: string, body: object) => + apiRequest(endpoint, {method: "PUT", body: JSON.stringify(body)}), + delete: (endpoint: string) => + apiRequest(endpoint, {method: "DELETE"}), +}; diff --git a/frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts b/frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts new file mode 100644 index 0000000..bd4627d --- /dev/null +++ b/frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts @@ -0,0 +1,7 @@ +/** + * DTO for changing user password + */ +export class ChangeUserPasswordRequestDto { + userId?: string; + password?: string; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/CreateUserRequestDto.ts b/frontend/src/api/dtos/CreateUserRequestDto.ts new file mode 100644 index 0000000..68d4012 --- /dev/null +++ b/frontend/src/api/dtos/CreateUserRequestDto.ts @@ -0,0 +1,9 @@ +import { UserDto } from "./UserDto.js"; + +/** + * DTO used for user creation + */ +export class CreateUserRequestDto { + userData?: UserDto; + password?: string; +} \ No newline at end of file diff --git a/frontend/src/api/points/UserPoint.ts b/frontend/src/api/points/UserPoint.ts new file mode 100644 index 0000000..4be5eb8 --- /dev/null +++ b/frontend/src/api/points/UserPoint.ts @@ -0,0 +1,24 @@ +import {apiClient} from "../apiClient"; +import type {UserDto} from "../dtos/UserDto"; +import type {CreateUserRequestDto} from "../dtos/CreateUserRequestDto"; +import type {ChangeUserPasswordRequestDto} from "../dtos/ChangeUserPasswordRequestDto"; + +export async function fetchCurrentUser(): Promise { + return apiClient.get("/user/me"); +} + +export async function fetchAllUsers(): Promise { + return apiClient.get("/user/list"); +} + +export async function createUser(dto: CreateUserRequestDto): Promise { + return apiClient.post("/user/create", dto); +} + +export async function updateUser(dto: UserDto): Promise { + return apiClient.post("/user/update", dto); +} + +export async function changePassword(dto: ChangeUserPasswordRequestDto) { + return apiClient.post("/user/change-password", dto); +} diff --git a/frontend/src/components/SettingsMenu.tsx b/frontend/src/components/SettingsMenu.tsx new file mode 100644 index 0000000..a99c6e3 --- /dev/null +++ b/frontend/src/components/SettingsMenu.tsx @@ -0,0 +1,61 @@ +// src/components/basics/SettingsMenu.tsx +import {X} from "lucide-react"; +import {useNavigate} from "react-router-dom"; +import type {LoginResponseDto} from "../api/dtos/LoginResponseDto"; +import {getUserUrl} from "../routes.ts"; + +/** + * Overlay settings menu that displays current user info + * and provides navigation to user management. + */ +type SettingsMenuProps = { + onClose: () => void; +}; + +export function SettingsMenu({onClose}: SettingsMenuProps) { + const navigate = useNavigate(); + const storedSession = localStorage.getItem("session"); + const loginData: LoginResponseDto | null = storedSession + ? JSON.parse(storedSession) + : null; + const user = loginData?.userData; + + return ( + + + {/* Close button */} + + + + + Einstellungen + + {user ? ( + <> + + {user.firstName} {user.lastName} + + + {user.role === "admin" ? "Administrator" : "Benutzer"} + + + { + navigate(getUserUrl()); + onClose(); + }} + className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition" + > + Benutzerverwaltung + + > + ) : ( + Nicht eingeloggt + )} + + + ); +} diff --git a/frontend/src/components/basics/PageContentLayout.tsx b/frontend/src/components/basics/PageContentLayout.tsx new file mode 100644 index 0000000..d4e5bce --- /dev/null +++ b/frontend/src/components/basics/PageContentLayout.tsx @@ -0,0 +1,47 @@ +// src/components/basics/PageContentLayout.tsx +import React, {type ReactNode, useState} from "react"; +import clsx from "clsx"; +import {Settings} from "lucide-react"; +import {SettingsMenu} from "../SettingsMenu"; + +/** + * Layout wrapper for the main content area of a page. + * Includes a settings button (top-right) that opens a global settings menu. + */ +type PageContentLayoutProps = React.HTMLAttributes & { + /** The main page content (e.g. header, body) */ + children: ReactNode; +}; + +export default function PageContentLayout({ + children, + className, + ...props + }: PageContentLayoutProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + + {/* Settings Button (top-right) */} + setIsMenuOpen(true)} + className="absolute top-4 right-4 p-2 rounded-full bg-white hover:bg-gray-200 shadow transition" + title="Einstellungen" + > + + + + {/* Page content */} + {children} + + {/* Overlay Settings Menu */} + {isMenuOpen && setIsMenuOpen(false)}/>} + + ); +} diff --git a/frontend/src/components/basics/PasswordField.tsx b/frontend/src/components/basics/PasswordField.tsx index 5d27850..169c145 100644 --- a/frontend/src/components/basics/PasswordField.tsx +++ b/frontend/src/components/basics/PasswordField.tsx @@ -7,12 +7,18 @@ type PasswordFieldProps = { onPasswordChanged: (password: string) => void onKeyDown: (event: React.KeyboardEvent) => void className?: string + placeholder?: string } /** * Password field component */ -export default function PasswordField({onPasswordChanged, onKeyDown, className = ""}: PasswordFieldProps) { +export default function PasswordField({ + onPasswordChanged, + onKeyDown, + placeholder = "Passwort", + className = "" + }: PasswordFieldProps) { const [showPassword, setShowPassword] = useState(false); const [password, setPassword] = useState(""); @@ -27,7 +33,7 @@ export default function PasswordField({onPasswordChanged, onKeyDown, className = changePassword(e.target.value)} onKeyDown={onKeyDown} diff --git a/frontend/src/components/recipes/RecipeDetailPage.tsx b/frontend/src/components/recipes/RecipeDetailPage.tsx index d185e5a..9c4a642 100644 --- a/frontend/src/components/recipes/RecipeDetailPage.tsx +++ b/frontend/src/components/recipes/RecipeDetailPage.tsx @@ -10,11 +10,11 @@ import {NumberedListItem} from "../basics/NumberedListItem.tsx"; import {ButtonType} from "../basics/BasicButtonDefinitions.ts"; import StickyHeader from "../basics/StickyHeader.tsx"; import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"; -import ContentBackground from "../basics/ContentBackground.tsx"; import ContentBody from "../basics/ContentBody.tsx"; import PageContainer from "../basics/PageContainer.tsx"; import {BoxContainer} from "../basics/BoxContainer.tsx"; import IngredientGroupDisplayListItem from "./IngredientGroupDisplayListItem.tsx"; +import PageContentLayout from "../basics/PageContentLayout.tsx"; /** @@ -86,7 +86,7 @@ export default function RecipeDetailPage() { /*Container spanning entire screen used to center content horizontally */ {/* Container defining the maximum width of the content */} - + {/* Header - remains in position when scrolling */} {recipeWorkingCopy.title} @@ -150,7 +150,7 @@ export default function RecipeDetailPage() { /> - + ) } diff --git a/frontend/src/components/users/ChangePasswordModal.tsx b/frontend/src/components/users/ChangePasswordModal.tsx new file mode 100644 index 0000000..fc34ccf --- /dev/null +++ b/frontend/src/components/users/ChangePasswordModal.tsx @@ -0,0 +1,65 @@ +// src/components/basics/ChangePasswordModal.tsx +import {useState} from "react"; +import {changePassword} from "../../api/points/UserPoint"; +import PasswordField from "../basics/PasswordField.tsx"; +import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"; +import Button from "../basics/Button.tsx"; +import {ButtonType} from "../basics/BasicButtonDefinitions.ts"; + +type ChangePasswordModalProps = { + userId?: string; + onClose: () => void; +}; + +export function ChangePasswordModal({userId, onClose}: ChangePasswordModalProps) { + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + + const handleSave = async () => { + if (password !== confirm) { + setError("Passwörter stimmen nicht überein."); + return; + } + try { + await changePassword({userId, password}); + onClose(); + } catch { + setError("Fehler beim Ändern des Passworts"); + } + }; + + return ( + + + Passwort ändern + + + + {error && {error}} + + + + + + + + ); +} diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx new file mode 100644 index 0000000..49d5b62 --- /dev/null +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -0,0 +1,269 @@ +import {useEffect, useState} from "react"; +import {createUser, fetchCurrentUser, updateUser} from "../../api/points/UserPoint"; +import type {UserDto} from "../../api/dtos/UserDto.ts"; // @todo add model and mapper! +import ContentBackground from "../basics/ContentBackground"; +import ContentBody from "../basics/ContentBody"; +import StickyHeader from "../basics/StickyHeader"; +import Button from "../basics/Button"; +import {ButtonType} from "../basics/BasicButtonDefinitions"; +import clsx from "clsx"; +import PageContainer from "../basics/PageContainer.tsx"; +import {ChangePasswordModal} from "./ChangePasswordModal.tsx"; +import PasswordField from "../basics/PasswordField.tsx"; +import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"; +import {Plus, X} from "lucide-react"; +import ButtonLink from "../basics/ButtonLink.tsx"; +import {getRecipeListUrl} from "../../routes.ts"; + +/** + * UserManagementPage + * ------------------- + * Displays a two-column layout: + * - Left: list of all users + * - Right: edit form for selected or new user + * + * Allows: + * - Admins to manage all users (add/edit) + * - Regular users to edit their own profile + * - Password changes via modal + */ +export default function UserManagementPage() { + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + const isAdmin = currentUser?.role === "admin"; + + // Load current user and user list (if admin) + useEffect(() => { + loadData().catch(console.error); + }, []); + + const loadData = async () => { + const me = await fetchCurrentUser(); + setCurrentUser(me); + + //if (me.role === "admin") { + // const allUsers = await fetchAllUsers(); + // setUsers(allUsers); + //} else { + setUsers([me]); + setSelectedUser(me); + //} + }; + /** Handles selecting a user from the list */ + const handleSelectUser = (user: UserDto) => { + setSelectedUser({...user}); + }; + + /** Handles saving user (create or update) */ + const handleSave = async () => { + if (!selectedUser) return; + setIsSaving(true); + try { + if (!selectedUser.id) { + //@todo check passwords! + const created = await createUser({userData: selectedUser, password}); + setUsers([...users, created]); + setSelectedUser(created); + } else { + const updated = await updateUser(selectedUser); + setUsers(users.map(u => (u.id === updated.id ? updated : u))); + setSelectedUser(updated); + } + } finally { + setIsSaving(false); + } + }; + + /** Opens password modal */ + const openPasswordModal = () => { + setPassword(""); + setConfirmPassword(""); + setIsPasswordModalOpen(true); + }; + + /** Handles creating a new user (admin only) */ + const handleAddUser = () => { + setSelectedUser({ + userName: "", + firstName: "", + lastName: "", + email: "", + role: "user", + }); + }; + + return ( + + + + + Benutzerverwaltung + + {isAdmin && ( + + )} + + + + + + + + {/* User List */} + + Benutzer + + {users.map((user) => ( + handleSelectUser(user)} + > + {user.firstName} {user.lastName} ({user.userName}) + + ))} + + + + {/* Edit Form */} + + {selectedUser ? ( + + + {selectedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"} + + { + e.preventDefault(); + handleSave(); + }} + className="flex flex-col gap-3 max-w-md" + > + {/* @todo create component for laben and input field combination */} + Benutzername + + setSelectedUser({...selectedUser, userName: e.target.value}) + } + /> + Vorname + + setSelectedUser({...selectedUser, firstName: e.target.value}) + } + /> + Nachname + + setSelectedUser({...selectedUser, lastName: e.target.value}) + } + /> + E-Mail + + setSelectedUser({...selectedUser, email: e.target.value}) + } + /> + + {isAdmin && (Benutzergruppe)} + {isAdmin && ( + // @todo style + + setSelectedUser({...selectedUser, role: e.target.value}) + } + > + Benutzer + Administrator + + )} + + {/* Show password field only when creating new user */} + {!selectedUser.id && (Passwort)} + {!selectedUser.id && ( + { + }} + /> + )} + {!selectedUser.id && (Passwort bestätigen)} + {!selectedUser.id && ( + { + }} + /> + )} + + {/* Change password button for existing user */} + {selectedUser.id && ( + + )} + + + + + + + + ) : ( + Bitte einen Benutzer auswählen. + )} + + + + + {/* Password Change Modal */} + {isPasswordModalOpen && ( + setIsPasswordModalOpen(false)} + /> + )} + + + ); +} diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index ba3b241..f9e39e4 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -2,17 +2,59 @@ * Routes for all pages */ // Route definitions using :id as placeholder for the id -export function getRootUrlDefinition() : string { return getRootUrl()} -export function getRecipeDetailsUrlDefinition() : string {return getRecipeDetailUrl(":id")} -export function getRecipeEditUrlDefinition() : string {return getRecipeEditUrl(":id")} -export function getRecipeAddUrlDefinition() : string {return getRecipeAddUrl()} -export function getRecipeListUrlDefinition() : string {return getRecipeListUrl()} -export function getLoginUrlDefinition() : string {return getLoginUrl()} +export function getRootUrlDefinition(): string { + return getRootUrl() +} + +export function getRecipeDetailsUrlDefinition(): string { + return getRecipeDetailUrl(":id") +} + +export function getRecipeEditUrlDefinition(): string { + return getRecipeEditUrl(":id") +} + +export function getRecipeAddUrlDefinition(): string { + return getRecipeAddUrl() +} + +export function getRecipeListUrlDefinition(): string { + return getRecipeListUrl() +} + +export function getLoginUrlDefinition(): string { + return getLoginUrl() +} + +export function getUserUrlDefinition(): string { + return getUserUrl() +} // URLs including id -export function getRootUrl () : string { return "/"} -export function getRecipeListUrl() : string {return "/recipe/list"} -export function getRecipeDetailUrl(id: string) : string {return "/recipe/" + id + "/card"} -export function getRecipeEditUrl(id: string) : string {return "/recipe/" + id + "/edit"} -export function getRecipeAddUrl() : string {return "/recipe/add"} -export function getLoginUrl() : string {return "/login"} \ No newline at end of file +export function getRootUrl(): string { + return "/" +} + +export function getRecipeListUrl(): string { + return "/recipe/list" +} + +export function getRecipeDetailUrl(id: string): string { + return "/recipe/" + id + "/card" +} + +export function getRecipeEditUrl(id: string): string { + return "/recipe/" + id + "/edit" +} + +export function getRecipeAddUrl(): string { + return "/recipe/add" +} + +export function getLoginUrl(): string { + return "/login" +} + +export function getUserUrl(): string { + return "/user" +} \ No newline at end of file
+ {user.firstName} {user.lastName} +
+ {user.role === "admin" ? "Administrator" : "Benutzer"} +
Nicht eingeloggt
{error}
Bitte einen Benutzer auswählen.