From 9e7ad622f9deb0dedac67d815dad1b0e3a95943b Mon Sep 17 00:00:00 2001 From: araemer Date: Sun, 2 Nov 2025 09:09:34 +0100 Subject: [PATCH 01/16] add basic user management --- frontend/src/App.tsx | 47 +-- frontend/src/api/apiClient.ts | 97 +++++++ .../api/dtos/ChangeUserPasswordRequestDto.ts | 7 + frontend/src/api/dtos/CreateUserRequestDto.ts | 9 + frontend/src/api/points/UserPoint.ts | 24 ++ frontend/src/components/SettingsMenu.tsx | 61 ++++ .../components/basics/PageContentLayout.tsx | 47 +++ .../src/components/basics/PasswordField.tsx | 10 +- .../components/recipes/RecipeDetailPage.tsx | 6 +- .../components/users/ChangePasswordModal.tsx | 65 +++++ .../components/users/UserManagementPage.tsx | 269 ++++++++++++++++++ frontend/src/routes.ts | 66 ++++- 12 files changed, 673 insertions(+), 35 deletions(-) create mode 100644 frontend/src/api/apiClient.ts create mode 100644 frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts create mode 100644 frontend/src/api/dtos/CreateUserRequestDto.ts create mode 100644 frontend/src/api/points/UserPoint.ts create mode 100644 frontend/src/components/SettingsMenu.tsx create mode 100644 frontend/src/components/basics/PageContentLayout.tsx create mode 100644 frontend/src/components/users/ChangePasswordModal.tsx create mode 100644 frontend/src/components/users/UserManagementPage.tsx 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"} +

+ + + + ) : ( +

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) */} + + + {/* 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 */} + + + setSelectedUser({...selectedUser, userName: e.target.value}) + } + /> + + + setSelectedUser({...selectedUser, firstName: e.target.value}) + } + /> + + + setSelectedUser({...selectedUser, lastName: e.target.value}) + } + /> + + + setSelectedUser({...selectedUser, email: e.target.value}) + } + /> + + {isAdmin && ()} + {isAdmin && ( + // @todo style + + )} + + {/* Show password field only when creating new user */} + {!selectedUser.id && ()} + {!selectedUser.id && ( + { + }} + /> + )} + {!selectedUser.id && ()} + {!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 From e539f9201bca9b11cbd13bfdf534889908246005 Mon Sep 17 00:00:00 2001 From: araemer Date: Wed, 5 Nov 2025 20:27:45 +0100 Subject: [PATCH 02/16] Styling and removing unnecessary ai-generated css --- .../components/users/UserManagementPage.tsx | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx index 49d5b62..2e9a83a 100644 --- a/frontend/src/components/users/UserManagementPage.tsx +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -101,37 +101,35 @@ export default function UserManagementPage() { return ( - -
-

Benutzerverwaltung

- - {isAdmin && ( -
+ )} + +
- {/* User List */} + {/* User List @todo selector component!*/}
-

Benutzer

+

Benutzer

    {users.map((user) => (
  • handleSelectUser(user)} @@ -146,7 +144,7 @@ export default function UserManagementPage() {
    {selectedUser ? (
    -

    +

    {selectedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"}

    Date: Thu, 13 Nov 2025 20:37:26 +0100 Subject: [PATCH 03/16] Add components for SelectField and TextLinkButton --- .../src/components/basics/SelectField.tsx | 25 ++++ .../src/components/basics/TextLinkButton.tsx | 20 ++++ .../components/users/UserManagementPage.tsx | 107 +++++++++--------- 3 files changed, 101 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/basics/SelectField.tsx create mode 100644 frontend/src/components/basics/TextLinkButton.tsx diff --git a/frontend/src/components/basics/SelectField.tsx b/frontend/src/components/basics/SelectField.tsx new file mode 100644 index 0000000..1dc0ecb --- /dev/null +++ b/frontend/src/components/basics/SelectField.tsx @@ -0,0 +1,25 @@ +type SelectFieldProps = { + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; + className?: string; +}; + +/** + * SelectField - A dropdown styled consistently with input fields + */ +export default function SelectField({value, onChange, options, className = ''}: SelectFieldProps) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/basics/TextLinkButton.tsx b/frontend/src/components/basics/TextLinkButton.tsx new file mode 100644 index 0000000..733abd6 --- /dev/null +++ b/frontend/src/components/basics/TextLinkButton.tsx @@ -0,0 +1,20 @@ +type TextLinkButtonProps = { + text: string; + onClick: () => void; + className?: string; +}; + +/** + * TextLinkButton - A button styled as a hyperlink + */ +export default function TextLinkButton({text, onClick, className = ''}: TextLinkButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx index 2e9a83a..9977ee4 100644 --- a/frontend/src/components/users/UserManagementPage.tsx +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -1,6 +1,6 @@ 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 type {UserDto} from "../../api/dtos/UserDto.ts"; import ContentBackground from "../basics/ContentBackground"; import ContentBody from "../basics/ContentBody"; import StickyHeader from "../basics/StickyHeader"; @@ -14,12 +14,14 @@ import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"; import {Plus, X} from "lucide-react"; import ButtonLink from "../basics/ButtonLink.tsx"; import {getRecipeListUrl} from "../../routes.ts"; +import TextLinkButton from "../basics/TextLinkButton.tsx"; +import SelectField from "../basics/SelectField.tsx"; /** * UserManagementPage * ------------------- * Displays a two-column layout: - * - Left: list of all users + * - Left: list of all users (wider on desktop) * - Right: edit form for selected or new user * * Allows: @@ -55,6 +57,7 @@ export default function UserManagementPage() { setSelectedUser(me); //} }; + /** Handles selecting a user from the list */ const handleSelectUser = (user: UserDto) => { setSelectedUser({...user}); @@ -98,6 +101,11 @@ export default function UserManagementPage() { }); }; + const roleOptions = [ + {value: "user", label: "Benutzer"}, + {value: "admin", label: "Administrator"} + ]; + return ( @@ -120,17 +128,17 @@ export default function UserManagementPage() { -
    - {/* User List @todo selector component!*/} -
    +
    + {/* User List - Wider on desktop, attached to left */} +

    Benutzer

    -
      +
        {users.map((user) => (
      • handleSelectUser(user)} > @@ -141,20 +149,13 @@ export default function UserManagementPage() {
    {/* 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 */} +
    + + + - {isAdmin && ()} {isAdmin && ( - // @todo style - + <> + + + setSelectedUser({...selectedUser, role: value}) + } + options={roleOptions} + /> + )} {/* Show password field only when creating new user */} - {!selectedUser.id && ()} {!selectedUser.id && ( - { - }} - /> - )} - {!selectedUser.id && ()} - {!selectedUser.id && ( - { - }} - /> + <> + + { + }} + /> + + + { + }} + /> + )} - {/* Change password button for existing user */} + {/* Change password link for existing user */} {selectedUser.id && ( -
    ) : (

    Bitte einen Benutzer auswählen.

    @@ -264,4 +269,4 @@ export default function UserManagementPage() { ); -} +} \ No newline at end of file From c3998ca03957c4c53f6965aa8762d6e579b7bc63 Mon Sep 17 00:00:00 2001 From: araemer Date: Thu, 13 Nov 2025 21:34:06 +0100 Subject: [PATCH 04/16] rename API files --- ...uestDto.ts => ChangeUserPasswordRequest.ts} | 2 +- ...eUserRequestDto.ts => CreateUserRequest.ts} | 4 ++-- frontend/src/api/dtos/CreateUserResponse.ts | 5 +++++ .../{LoginRequestDto.ts => LoginRequest.ts} | 2 +- frontend/src/api/dtos/LoginResponse.ts | 10 ++++++++++ frontend/src/api/dtos/LoginResponseDto.ts | 10 ---------- frontend/src/api/points/AuthPoint.ts | 18 +++++++++--------- frontend/src/api/points/UserPoint.ts | 9 +++++---- frontend/src/components/LoginPage.tsx | 8 ++++---- frontend/src/components/SettingsMenu.tsx | 4 ++-- .../components/users/UserManagementPage.tsx | 10 +++++++--- 11 files changed, 46 insertions(+), 36 deletions(-) rename frontend/src/api/dtos/{ChangeUserPasswordRequestDto.ts => ChangeUserPasswordRequest.ts} (65%) rename frontend/src/api/dtos/{CreateUserRequestDto.ts => CreateUserRequest.ts} (52%) create mode 100644 frontend/src/api/dtos/CreateUserResponse.ts rename frontend/src/api/dtos/{LoginRequestDto.ts => LoginRequest.ts} (71%) create mode 100644 frontend/src/api/dtos/LoginResponse.ts delete mode 100644 frontend/src/api/dtos/LoginResponseDto.ts diff --git a/frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts b/frontend/src/api/dtos/ChangeUserPasswordRequest.ts similarity index 65% rename from frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts rename to frontend/src/api/dtos/ChangeUserPasswordRequest.ts index bd4627d..e7f822e 100644 --- a/frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts +++ b/frontend/src/api/dtos/ChangeUserPasswordRequest.ts @@ -1,7 +1,7 @@ /** * DTO for changing user password */ -export class ChangeUserPasswordRequestDto { +export class ChangeUserPasswordRequest { userId?: string; password?: string; } \ No newline at end of file diff --git a/frontend/src/api/dtos/CreateUserRequestDto.ts b/frontend/src/api/dtos/CreateUserRequest.ts similarity index 52% rename from frontend/src/api/dtos/CreateUserRequestDto.ts rename to frontend/src/api/dtos/CreateUserRequest.ts index 68d4012..7eb229f 100644 --- a/frontend/src/api/dtos/CreateUserRequestDto.ts +++ b/frontend/src/api/dtos/CreateUserRequest.ts @@ -1,9 +1,9 @@ -import { UserDto } from "./UserDto.js"; +import {UserDto} from "./UserDto.js"; /** * DTO used for user creation */ -export class CreateUserRequestDto { +export class CreateUserRequest { userData?: UserDto; password?: string; } \ No newline at end of file diff --git a/frontend/src/api/dtos/CreateUserResponse.ts b/frontend/src/api/dtos/CreateUserResponse.ts new file mode 100644 index 0000000..09408c1 --- /dev/null +++ b/frontend/src/api/dtos/CreateUserResponse.ts @@ -0,0 +1,5 @@ +import { UserDto } from "./UserDto.js"; + +export class CreateUserResponse{ + userData?: UserDto; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/LoginRequestDto.ts b/frontend/src/api/dtos/LoginRequest.ts similarity index 71% rename from frontend/src/api/dtos/LoginRequestDto.ts rename to frontend/src/api/dtos/LoginRequest.ts index a3da6ee..70d1e09 100644 --- a/frontend/src/api/dtos/LoginRequestDto.ts +++ b/frontend/src/api/dtos/LoginRequest.ts @@ -1,7 +1,7 @@ /** * Defines a login request */ -export class LoginRequestDto { +export class LoginRequest { userName?: string; password?: string; } \ No newline at end of file diff --git a/frontend/src/api/dtos/LoginResponse.ts b/frontend/src/api/dtos/LoginResponse.ts new file mode 100644 index 0000000..1e571ed --- /dev/null +++ b/frontend/src/api/dtos/LoginResponse.ts @@ -0,0 +1,10 @@ +import {UserDto} from "./UserDto.js"; + +/** + * Response to a successful login + */ +export class LoginResponse { + userData?: UserDto; + token?: string; + expiryDate?: Date; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/LoginResponseDto.ts b/frontend/src/api/dtos/LoginResponseDto.ts deleted file mode 100644 index fdb5a39..0000000 --- a/frontend/src/api/dtos/LoginResponseDto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserDto } from "./UserDto.js"; - -/** - * Response to a successful login - */ -export class LoginResponseDto { - userData?: UserDto; - token?: string; - expiryDate? : Date; -} \ No newline at end of file diff --git a/frontend/src/api/points/AuthPoint.ts b/frontend/src/api/points/AuthPoint.ts index bf34abd..603eb1c 100644 --- a/frontend/src/api/points/AuthPoint.ts +++ b/frontend/src/api/points/AuthPoint.ts @@ -1,6 +1,6 @@ -import type { LoginRequestDto } from "../dtos/LoginRequestDto"; -import type { LoginResponseDto } from "../dtos/LoginResponseDto"; -import { postJson } from "../utils/requests"; +import type {LoginRequest} from "../dtos/LoginRequest.ts"; +import type {LoginResponse} from "../dtos/LoginResponse.ts"; +import {postJson} from "../utils/requests"; /** @@ -16,11 +16,11 @@ const AUTH_URL = `${BASE_URL}/auth` /** - * Create new Recipe - * @param recipe Recipe to create - * @returns Saved recipe + * Login to recipe app + * @param loginRequest Login Requets + * @returns LoginResponse */ -export async function login(requestDto: LoginRequestDto): Promise { - const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(requestDto), false); - return res.json(); +export async function login(loginRequest: LoginRequest): Promise { + const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(loginRequest), false); + return res.json(); } diff --git a/frontend/src/api/points/UserPoint.ts b/frontend/src/api/points/UserPoint.ts index 4be5eb8..0614c2a 100644 --- a/frontend/src/api/points/UserPoint.ts +++ b/frontend/src/api/points/UserPoint.ts @@ -1,7 +1,8 @@ import {apiClient} from "../apiClient"; import type {UserDto} from "../dtos/UserDto"; -import type {CreateUserRequestDto} from "../dtos/CreateUserRequestDto"; -import type {ChangeUserPasswordRequestDto} from "../dtos/ChangeUserPasswordRequestDto"; +import type {CreateUserRequest} from "../dtos/CreateUserRequest.ts"; +import type {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.ts"; +import type {CreateUserResponse} from "../dtos/CreateUserResponse.ts"; export async function fetchCurrentUser(): Promise { return apiClient.get("/user/me"); @@ -11,7 +12,7 @@ export async function fetchAllUsers(): Promise { return apiClient.get("/user/list"); } -export async function createUser(dto: CreateUserRequestDto): Promise { +export async function createUser(dto: CreateUserRequest): Promise { return apiClient.post("/user/create", dto); } @@ -19,6 +20,6 @@ export async function updateUser(dto: UserDto): Promise { return apiClient.post("/user/update", dto); } -export async function changePassword(dto: ChangeUserPasswordRequestDto) { +export async function changePassword(dto: ChangeUserPasswordRequest) { return apiClient.post("/user/change-password", dto); } diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx index 9f6f054..445544b 100644 --- a/frontend/src/components/LoginPage.tsx +++ b/frontend/src/components/LoginPage.tsx @@ -1,7 +1,7 @@ import {useState} from "react"; import Button from "./basics/Button"; -import type {LoginRequestDto} from "../api/dtos/LoginRequestDto"; -import type {LoginResponseDto} from "../api/dtos/LoginResponseDto"; +import type {LoginRequest} from "../api/dtos/LoginRequest.ts"; +import type {LoginResponse} from "../api/dtos/LoginResponse.ts"; import {login} from "../api/points/AuthPoint"; import {getRecipeListUrl} from "../routes"; import {useNavigate} from "react-router-dom"; @@ -20,14 +20,14 @@ export default function LoginPage() { * Login */ const executeLogin = async () => { - const dto: LoginRequestDto = { + const dto: LoginRequest = { userName, password, }; try { console.log("Trying to log in with " + dto.userName); - const loginResponse: LoginResponseDto = await login(dto); + const loginResponse: LoginResponse = await login(dto); localStorage.setItem("session", JSON.stringify(loginResponse)); console.log("Successfully logged in as " + loginResponse.userData?.userName); setErrorMessage(null); diff --git a/frontend/src/components/SettingsMenu.tsx b/frontend/src/components/SettingsMenu.tsx index a99c6e3..7541e28 100644 --- a/frontend/src/components/SettingsMenu.tsx +++ b/frontend/src/components/SettingsMenu.tsx @@ -1,7 +1,7 @@ // src/components/basics/SettingsMenu.tsx import {X} from "lucide-react"; import {useNavigate} from "react-router-dom"; -import type {LoginResponseDto} from "../api/dtos/LoginResponseDto"; +import type {LoginResponse} from "../api/dtos/LoginResponse.ts"; import {getUserUrl} from "../routes.ts"; /** @@ -15,7 +15,7 @@ type SettingsMenuProps = { export function SettingsMenu({onClose}: SettingsMenuProps) { const navigate = useNavigate(); const storedSession = localStorage.getItem("session"); - const loginData: LoginResponseDto | null = storedSession + const loginData: LoginResponse | null = storedSession ? JSON.parse(storedSession) : null; const user = loginData?.userData; diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx index 9977ee4..1355214 100644 --- a/frontend/src/components/users/UserManagementPage.tsx +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -16,6 +16,7 @@ import ButtonLink from "../basics/ButtonLink.tsx"; import {getRecipeListUrl} from "../../routes.ts"; import TextLinkButton from "../basics/TextLinkButton.tsx"; import SelectField from "../basics/SelectField.tsx"; +import type {CreateUserResponse} from "../../api/dtos/CreateUserResponse.ts"; /** * UserManagementPage @@ -70,9 +71,12 @@ export default function UserManagementPage() { try { if (!selectedUser.id) { //@todo check passwords! - const created = await createUser({userData: selectedUser, password}); - setUsers([...users, created]); - setSelectedUser(created); + const response: CreateUserResponse = await createUser({userData: selectedUser, password}); + const userDto = response.userData; + if (userDto) { + setUsers([...users, userDto]); + setSelectedUser(userDto); + } } else { const updated = await updateUser(selectedUser); setUsers(users.map(u => (u.id === updated.id ? updated : u))); From ba054ef2b9bffe7fdcdd36414e0eba51fdef73b9 Mon Sep 17 00:00:00 2001 From: araemer Date: Wed, 19 Nov 2025 20:51:20 +0100 Subject: [PATCH 05/16] Add missing key to list --- frontend/src/components/recipes/RecipeDetailPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/components/recipes/RecipeDetailPage.tsx b/frontend/src/components/recipes/RecipeDetailPage.tsx index 9c4a642..9df3b05 100644 --- a/frontend/src/components/recipes/RecipeDetailPage.tsx +++ b/frontend/src/components/recipes/RecipeDetailPage.tsx @@ -123,6 +123,7 @@ export default function RecipeDetailPage() {
      {recipeWorkingCopy.ingredientGroupList.map((group, i) => ( From ac3450239ecba757c01c46b56774daf120a1c524 Mon Sep 17 00:00:00 2001 From: araemer Date: Thu, 20 Nov 2025 20:16:56 +0100 Subject: [PATCH 06/16] Add loading UserList for admin user --- frontend/src/api/dtos/UserListResponse.ts | 8 ++++++++ frontend/src/api/points/UserPoint.ts | 5 +++-- .../recipes/IngredientGroupListEditor.tsx | 1 + .../components/users/UserManagementPage.tsx | 18 ++++++++++-------- 4 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/dtos/UserListResponse.ts diff --git a/frontend/src/api/dtos/UserListResponse.ts b/frontend/src/api/dtos/UserListResponse.ts new file mode 100644 index 0000000..ed6b669 --- /dev/null +++ b/frontend/src/api/dtos/UserListResponse.ts @@ -0,0 +1,8 @@ +import {UserDto} from "./UserDto.js"; + +/** + * API response for delivering a list of users + */ +export class UserListResponse { + valueList: UserDto[] = []; +} \ No newline at end of file diff --git a/frontend/src/api/points/UserPoint.ts b/frontend/src/api/points/UserPoint.ts index 0614c2a..02d0695 100644 --- a/frontend/src/api/points/UserPoint.ts +++ b/frontend/src/api/points/UserPoint.ts @@ -3,13 +3,14 @@ import type {UserDto} from "../dtos/UserDto"; import type {CreateUserRequest} from "../dtos/CreateUserRequest.ts"; import type {ChangeUserPasswordRequest} from "../dtos/ChangeUserPasswordRequest.ts"; import type {CreateUserResponse} from "../dtos/CreateUserResponse.ts"; +import type {UserListResponse} from "../dtos/UserListResponse.ts"; export async function fetchCurrentUser(): Promise { return apiClient.get("/user/me"); } -export async function fetchAllUsers(): Promise { - return apiClient.get("/user/list"); +export async function fetchAllUsers(): Promise { + return apiClient.get("/user/all"); } export async function createUser(dto: CreateUserRequest): Promise { diff --git a/frontend/src/components/recipes/IngredientGroupListEditor.tsx b/frontend/src/components/recipes/IngredientGroupListEditor.tsx index ddbfb06..6cf42af 100644 --- a/frontend/src/components/recipes/IngredientGroupListEditor.tsx +++ b/frontend/src/components/recipes/IngredientGroupListEditor.tsx @@ -50,6 +50,7 @@ export function IngredientGroupListEditor({ingredientGroupList, onChange}: Ingre {ingredientGroupList.map((ingGrp, index) => ( Date: Sun, 30 Nov 2025 07:36:47 +0100 Subject: [PATCH 07/16] Sort user list, preselect own user, check password before saving --- .../components/users/UserManagementPage.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx index 3bac1aa..6e230b4 100644 --- a/frontend/src/components/users/UserManagementPage.tsx +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -38,6 +38,7 @@ export default function UserManagementPage() { const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); const [isSaving, setIsSaving] = useState(false); const isAdmin = currentUser?.role === "admin"; @@ -53,8 +54,22 @@ export default function UserManagementPage() { if (me.role === "admin") { const userResponse: UserListResponse = await fetchAllUsers(); - setUsers(userResponse.valueList); - console.log(users) + + // Sort users alphabetically by last name, then first name + const sortedUsers = userResponse.valueList.sort((a, b) => { + // Compare last names first + const lastNameCompare = (a.lastName || "").localeCompare(b.lastName || ""); + if (lastNameCompare !== 0) { + return lastNameCompare; + } + // If last names are equal, compare first names + return (a.firstName || "").localeCompare(b.firstName || ""); + }); + + setUsers(sortedUsers); + + // Pre-select current user (admin's own profile) + setSelectedUser(me); } else { setUsers([me]); setSelectedUser(me); @@ -72,15 +87,22 @@ export default function UserManagementPage() { setIsSaving(true); try { if (!selectedUser.id) { - //@todo check passwords! + // New user - check passwords and save + if (confirmPassword !== password) { + setPasswordError("Passwords do not match"); + return; + } const response: CreateUserResponse = await createUser({userData: selectedUser, password}); const userDto = response.userData; if (userDto) { + // add user to list and slect setUsers([...users, userDto]); setSelectedUser(userDto); } } else { + // existing user - update const updated = await updateUser(selectedUser); + // update user data in user list and select correct user setUsers(users.map(u => (u.id === updated.id ? updated : u))); setSelectedUser(updated); } @@ -107,6 +129,7 @@ export default function UserManagementPage() { }); }; + // @todo API enum! const roleOptions = [ {value: "user", label: "Benutzer"}, {value: "admin", label: "Administrator"} @@ -148,7 +171,7 @@ export default function UserManagementPage() { )} onClick={() => handleSelectUser(user)} > - {user.firstName} {user.lastName} ({user.userName}) + {user.lastName}, {user.firstName} ({user.userName}) ))}
    @@ -232,6 +255,7 @@ export default function UserManagementPage() { onKeyDown={() => { }} /> + {passwordError &&

    {passwordError}

    } )} From bd6ee25910dcd5b2a10b19da5a300b9049859d04 Mon Sep 17 00:00:00 2001 From: araemer Date: Sun, 30 Nov 2025 08:03:18 +0100 Subject: [PATCH 08/16] Layout changes --- frontend/src/components/basics/ErrorPopup.tsx | 41 +++ .../src/components/users/UserEditForm.tsx | 160 ++++++++++ frontend/src/components/users/UserList.tsx | 48 +++ .../components/users/UserManagementPage.tsx | 293 ++++++------------ 4 files changed, 350 insertions(+), 192 deletions(-) create mode 100644 frontend/src/components/basics/ErrorPopup.tsx create mode 100644 frontend/src/components/users/UserEditForm.tsx create mode 100644 frontend/src/components/users/UserList.tsx diff --git a/frontend/src/components/basics/ErrorPopup.tsx b/frontend/src/components/basics/ErrorPopup.tsx new file mode 100644 index 0000000..1fc93ec --- /dev/null +++ b/frontend/src/components/basics/ErrorPopup.tsx @@ -0,0 +1,41 @@ +import {AlertCircle, X} from "lucide-react"; + +type ErrorPopupProps = { + message: string; + onClose: () => void; +}; + +/** + * ErrorPopup - Displays error messages in a modal overlay + */ +export default function ErrorPopup({message, onClose}: ErrorPopupProps) { + return ( +
    +
    +
    +
    + +

    Fehler

    +
    + +
    + +

    {message}

    + +
    + +
    +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/components/users/UserEditForm.tsx b/frontend/src/components/users/UserEditForm.tsx new file mode 100644 index 0000000..6ca0f30 --- /dev/null +++ b/frontend/src/components/users/UserEditForm.tsx @@ -0,0 +1,160 @@ +import {useState} from "react"; +import type {UserDto} from "../../api/dtos/UserDto"; +import Button from "../basics/Button"; +import {ButtonType} from "../basics/BasicButtonDefinitions"; +import PasswordField from "../basics/PasswordField"; +import ButtonGroupLayout from "../basics/ButtonGroupLayout"; +import TextLinkButton from "../basics/TextLinkButton"; +import SelectField from "../basics/SelectField"; + +type UserEditFormProps = { + user: UserDto; + isAdmin: boolean; + isSaving: boolean; + onSave: (user: UserDto, password?: string) => Promise; + onCancel: () => void; + onOpenPasswordModal: () => void; +}; + +/** + * UserEditForm - Form for creating/editing a user + */ +export default function UserEditForm({ + user, + isAdmin, + isSaving, + onSave, + onCancel, + onOpenPasswordModal, + }: UserEditFormProps) { + const [editedUser, setEditedUser] = useState({...user}); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordError, setPasswordError] = useState(""); + + const handleSave = async () => { + if (!editedUser.id) { + // New user - validate passwords + if (confirmPassword !== password) { + setPasswordError("Passwörter stimmen nicht überein"); + return; + } + if (!password || password.length === 0) { + setPasswordError("Passwort ist erforderlich"); + return; + } + setPasswordError(""); + await onSave(editedUser, password); + } else { + // Existing user + await onSave(editedUser); + } + }; + + // @todo API string + const roleOptions = [ + {value: "user", label: "Benutzer"}, + {value: "admin", label: "Administrator"}, + ]; + + return ( +
    +

    {editedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"}

    +
    + + + setEditedUser({...editedUser, userName: e.target.value}) + } + /> + + + + setEditedUser({...editedUser, firstName: e.target.value}) + } + /> + + + + setEditedUser({...editedUser, lastName: e.target.value}) + } + /> + + + + setEditedUser({...editedUser, email: e.target.value}) + } + /> + + {isAdmin && ( + <> + + + setEditedUser({...editedUser, role: value}) + } + options={roleOptions} + /> + + )} + + {/* Show password field only when creating new user */} + {!editedUser.id && ( + <> + + { + }} + /> + + + { + }} + /> + {passwordError &&

    {passwordError}

    } + + )} + + {/* Change password link for existing user */} + {editedUser.id && ( +
    + +
    + )} + + +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/components/users/UserList.tsx b/frontend/src/components/users/UserList.tsx new file mode 100644 index 0000000..c888a0a --- /dev/null +++ b/frontend/src/components/users/UserList.tsx @@ -0,0 +1,48 @@ +import clsx from "clsx"; +import type {UserDto} from "../../api/dtos/UserDto"; + +type UserListProps = { + users: UserDto[]; + selectedUser: UserDto | null; + onSelectUser: (user: UserDto) => void; +}; + +/** + * UserList - Displays a list of users with selection + */ +export default function UserList({users, selectedUser, onSelectUser}: UserListProps) { + const formatUserName = (user: UserDto) => { + const parts = []; + if (user.lastName) parts.push(user.lastName); + if (user.firstName) parts.push(user.firstName); + return parts.length > 0 ? parts.join(", ") : ""; + }; + + return ( +
    +

    Benutzer

    +
      + {users.map((user) => ( +
    • onSelectUser(user)} + > +
      + {formatUserName(user)} +
      +
      + {user.userName} +
      +
    • + ))} +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/src/components/users/UserManagementPage.tsx b/frontend/src/components/users/UserManagementPage.tsx index 6e230b4..6c7c72f 100644 --- a/frontend/src/components/users/UserManagementPage.tsx +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -1,29 +1,28 @@ import {useEffect, useState} from "react"; -import {createUser, fetchAllUsers, fetchCurrentUser, updateUser} from "../../api/points/UserPoint"; -import type {UserDto} from "../../api/dtos/UserDto.ts"; +import {createUser, fetchAllUsers, fetchCurrentUser, updateUser,} from "../../api/points/UserPoint"; +import type {UserDto} from "../../api/dtos/UserDto"; 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 PageContainer from "../basics/PageContainer"; +import {ChangePasswordModal} from "./ChangePasswordModal"; +import ButtonGroupLayout from "../basics/ButtonGroupLayout"; import {Plus, X} from "lucide-react"; -import ButtonLink from "../basics/ButtonLink.tsx"; -import {getRecipeListUrl} from "../../routes.ts"; -import TextLinkButton from "../basics/TextLinkButton.tsx"; -import SelectField from "../basics/SelectField.tsx"; -import type {CreateUserResponse} from "../../api/dtos/CreateUserResponse.ts"; -import type {UserListResponse} from "../../api/dtos/UserListResponse.ts"; +import ButtonLink from "../basics/ButtonLink"; +import {getRecipeListUrl} from "../../routes"; +import type {CreateUserResponse} from "../../api/dtos/CreateUserResponse"; +import type {UserListResponse} from "../../api/dtos/UserListResponse"; +import UserList from "./UserList"; +import UserEditForm from "./UserEditForm"; +import ErrorPopup from "../basics/ErrorPopup"; /** * UserManagementPage * ------------------- * Displays a two-column layout: - * - Left: list of all users (wider on desktop) + * - Left: list of all users * - Right: edit form for selected or new user * * Allows: @@ -36,43 +35,49 @@ export default function UserManagementPage() { const [selectedUser, setSelectedUser] = useState(null); const [currentUser, setCurrentUser] = useState(null); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordError, setPasswordError] = useState(""); const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); - const isAdmin = currentUser?.role === "admin"; + //@todo API enum + const adminRole: string = "admin"; + const isAdmin = currentUser?.role === adminRole; // Load current user and user list (if admin) useEffect(() => { - loadData().catch(console.error); + loadData(); }, []); const loadData = async () => { - const me = await fetchCurrentUser(); - setCurrentUser(me); + try { + const me = await fetchCurrentUser(); + setCurrentUser(me); - if (me.role === "admin") { - const userResponse: UserListResponse = await fetchAllUsers(); + if (me.role === adminRole) { + const userResponse: UserListResponse = await fetchAllUsers(); - // Sort users alphabetically by last name, then first name - const sortedUsers = userResponse.valueList.sort((a, b) => { - // Compare last names first - const lastNameCompare = (a.lastName || "").localeCompare(b.lastName || ""); - if (lastNameCompare !== 0) { - return lastNameCompare; - } - // If last names are equal, compare first names - return (a.firstName || "").localeCompare(b.firstName || ""); - }); + // Sort users alphabetically by last name, then first name + const sortedUsers = userResponse.valueList.sort((a, b) => { + const lastNameCompare = (a.lastName || "").localeCompare( + b.lastName || "" + ); + if (lastNameCompare !== 0) { + return lastNameCompare; + } + return (a.firstName || "").localeCompare(b.firstName || ""); + }); - setUsers(sortedUsers); - - // Pre-select current user (admin's own profile) - setSelectedUser(me); - } else { - setUsers([me]); - setSelectedUser(me); + setUsers(sortedUsers); + setSelectedUser(me); + } else { + setUsers([me]); + setSelectedUser(me); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Fehler beim Laden der Benutzerdaten" + ); } }; @@ -82,30 +87,42 @@ export default function UserManagementPage() { }; /** Handles saving user (create or update) */ - const handleSave = async () => { - if (!selectedUser) return; + const handleSave = async (user: UserDto, password?: string) => { setIsSaving(true); + setError(null); try { - if (!selectedUser.id) { - // New user - check passwords and save - if (confirmPassword !== password) { - setPasswordError("Passwords do not match"); - return; - } - const response: CreateUserResponse = await createUser({userData: selectedUser, password}); + if (!user.id) { + // New user + const response: CreateUserResponse = await createUser({ + userData: user, + password: password || "", + }); const userDto = response.userData; if (userDto) { - // add user to list and slect - setUsers([...users, userDto]); + const newUsers = [...users, userDto].sort((a, b) => { + const lastNameCompare = (a.lastName || "").localeCompare( + b.lastName || "" + ); + if (lastNameCompare !== 0) { + return lastNameCompare; + } + return (a.firstName || "").localeCompare(b.firstName || ""); + }); + setUsers(newUsers); setSelectedUser(userDto); } } else { - // existing user - update - const updated = await updateUser(selectedUser); - // update user data in user list and select correct user - setUsers(users.map(u => (u.id === updated.id ? updated : u))); + // Existing user + const updated = await updateUser(user); + setUsers(users.map((u) => (u.id === updated.id ? updated : u))); setSelectedUser(updated); } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Fehler beim Speichern der Benutzerdaten" + ); } finally { setIsSaving(false); } @@ -113,8 +130,6 @@ export default function UserManagementPage() { /** Opens password modal */ const openPasswordModal = () => { - setPassword(""); - setConfirmPassword(""); setIsPasswordModalOpen(true); }; @@ -129,16 +144,10 @@ export default function UserManagementPage() { }); }; - // @todo API enum! - const roleOptions = [ - {value: "user", label: "Benutzer"}, - {value: "admin", label: "Administrator"} - ]; - return ( - +

    Benutzerverwaltung

    {isAdmin && ( @@ -149,141 +158,36 @@ export default function UserManagementPage() { buttonType={ButtonType.PrimaryButton} /> )} - +
    - -
    - {/* User List - Wider on desktop, attached to left */} -
    -

    Benutzer

    -
      - {users.map((user) => ( -
    • handleSelectUser(user)} - > - {user.lastName}, {user.firstName} ({user.userName}) -
    • - ))} -
    + +
    + {/* User List - Left side, no padding */} +
    +
    - {/* Edit Form */} -
    + {/* Edit Form - Right side, takes remaining space */} +
    {selectedUser ? ( -
    -

    - {selectedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"} -

    -
    - - - setSelectedUser({...selectedUser, userName: e.target.value}) - } - /> - - - - setSelectedUser({...selectedUser, firstName: e.target.value}) - } - /> - - - - setSelectedUser({...selectedUser, lastName: e.target.value}) - } - /> - - - - setSelectedUser({...selectedUser, email: e.target.value}) - } - /> - - {isAdmin && ( - <> - - - setSelectedUser({...selectedUser, role: value}) - } - options={roleOptions} - /> - - )} - - {/* Show password field only when creating new user */} - {!selectedUser.id && ( - <> - - { - }} - /> - - - { - }} - /> - {passwordError &&

    {passwordError}

    } - - )} - - {/* Change password link for existing user */} - {selectedUser.id && ( -
    - -
    - )} - - -
    -
    + ) : ( -

    Bitte einen Benutzer auswählen.

    +
    +

    Bitte einen Benutzer auswählen.

    +
    )}
    @@ -296,6 +200,11 @@ export default function UserManagementPage() { onClose={() => setIsPasswordModalOpen(false)} /> )} + + {/* Error Popup */} + {error && ( + setError(null)}/> + )} ); From e6fd6d7d6f9a9fb87ba5d19d4420f6b2b031419c Mon Sep 17 00:00:00 2001 From: araemer Date: Sun, 30 Nov 2025 08:42:07 +0100 Subject: [PATCH 09/16] Enum for user role - somehow user page is currently unresponsive. Probably because of previous layout changes --- frontend/src/api/dtos/UserDto.ts | 11 ++-- frontend/src/api/enums/UserRole.ts | 52 +++++++++++++++++++ .../src/components/basics/SelectField.tsx | 34 +++++++++--- .../src/components/users/UserEditForm.tsx | 10 ++-- .../components/users/UserManagementPage.tsx | 8 +-- 5 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 frontend/src/api/enums/UserRole.ts diff --git a/frontend/src/api/dtos/UserDto.ts b/frontend/src/api/dtos/UserDto.ts index 972cad6..3aaa4c6 100644 --- a/frontend/src/api/dtos/UserDto.ts +++ b/frontend/src/api/dtos/UserDto.ts @@ -1,9 +1,10 @@ -import { AbstractDto } from "./AbstractDto.ts"; +import {AbstractDto} from "./AbstractDto"; +import {UserRole} from "../enums/UserRole"; -export class UserDto extends AbstractDto { +export interface UserDto extends AbstractDto { firstName?: string; lastName?: string; - userName!: string; - email!: string; - role?: string; + userName: string; + email: string; + role?: UserRole; } \ No newline at end of file diff --git a/frontend/src/api/enums/UserRole.ts b/frontend/src/api/enums/UserRole.ts new file mode 100644 index 0000000..d4f5f53 --- /dev/null +++ b/frontend/src/api/enums/UserRole.ts @@ -0,0 +1,52 @@ +/** + * User roles - Frontend version + * Content should match backend enum exactly + * However, we cannot use a basic enum here as we're using erasableSyntaxOnly. + */ +export const UserRole = { + USER: "user", + ADMIN: "admin", +} as const; + +// Create a type from the values +export type UserRole = typeof UserRole[keyof typeof UserRole]; + +/** + * Helper functions for UserRole in frontend + */ +export const UserRoleHelper = { + /** + * Get display name for role (German) + */ + getDisplayName(role: UserRole): string { + const displayNames: Record = { + [UserRole.USER]: "Benutzer", + [UserRole.ADMIN]: "Administrator", + }; + return displayNames[role]; + }, + + /** + * Get all roles with display names for dropdowns + */ + getRoleOptions(): Array<{ value: UserRole; label: string }> { + return Object.values(UserRole).map((role) => ({ + value: role, + label: this.getDisplayName(role), + })); + }, + + /** + * Check if a string is a valid role + */ + isValidRole(value: string): value is UserRole { + return Object.values(UserRole).includes(value as UserRole); + }, + + /** + * Get all role values + */ + getAllRoles(): UserRole[] { + return Object.values(UserRole); + }, +}; \ No newline at end of file diff --git a/frontend/src/components/basics/SelectField.tsx b/frontend/src/components/basics/SelectField.tsx index 1dc0ecb..e8c560a 100644 --- a/frontend/src/components/basics/SelectField.tsx +++ b/frontend/src/components/basics/SelectField.tsx @@ -1,18 +1,40 @@ -type SelectFieldProps = { - value: string; - onChange: (value: string) => void; - options: { value: string; label: string }[]; +type SelectFieldProps = { + value: T; + onChange: (value: T) => void; + options: { value: T; label: string }[]; className?: string; }; /** * SelectField - A dropdown styled consistently with input fields + * Generic component that works with any string-based type (including string literals and enums) + * + * @example + * // With UserRole type + * + * value={user.role} + * onChange={(role) => setUser({...user, role})} + * options={UserRoleHelper.getRoleOptions()} + * /> + * + * @example + * // With plain strings + * + * value={status} + * onChange={setStatus} + * options={[{value: "active", label: "Active"}, {value: "inactive", label: "Inactive"}]} + * /> */ -export default function SelectField({value, onChange, options, className = ''}: SelectFieldProps) { +export default function SelectField({ + value, + onChange, + options, + className = '' + }: SelectFieldProps) { return (