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/ChangeUserPasswordRequest.ts b/frontend/src/api/dtos/ChangeUserPasswordRequest.ts new file mode 100644 index 0000000..e7f822e --- /dev/null +++ b/frontend/src/api/dtos/ChangeUserPasswordRequest.ts @@ -0,0 +1,7 @@ +/** + * DTO for changing user password + */ +export class ChangeUserPasswordRequest { + userId?: string; + password?: string; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/CreateUserRequest.ts b/frontend/src/api/dtos/CreateUserRequest.ts new file mode 100644 index 0000000..0aa0464 --- /dev/null +++ b/frontend/src/api/dtos/CreateUserRequest.ts @@ -0,0 +1,9 @@ +import type {UserDto} from "./UserDto.js"; + +/** + * DTO used for user creation + */ +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/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/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/endpoints/AuthRestResource.ts b/frontend/src/api/endpoints/AuthRestResource.ts new file mode 100644 index 0000000..603eb1c --- /dev/null +++ b/frontend/src/api/endpoints/AuthRestResource.ts @@ -0,0 +1,26 @@ +import type {LoginRequest} from "../dtos/LoginRequest.ts"; +import type {LoginResponse} from "../dtos/LoginResponse.ts"; +import {postJson} from "../utils/requests"; + + +/** + * Util for handling the recipe api + */ +// read base url from .env file +const BASE_URL = import.meta.env.VITE_API_BASE; + +/** + * URL for handling recipes + */ +const AUTH_URL = `${BASE_URL}/auth` + + +/** + * Login to recipe app + * @param loginRequest Login Requets + * @returns LoginResponse + */ +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/CompactRecipePoint.ts b/frontend/src/api/endpoints/CompactRecipeRestResource.ts similarity index 100% rename from frontend/src/api/points/CompactRecipePoint.ts rename to frontend/src/api/endpoints/CompactRecipeRestResource.ts diff --git a/frontend/src/api/points/RecipePoint.ts b/frontend/src/api/endpoints/RecipeRestResource.ts similarity index 100% rename from frontend/src/api/points/RecipePoint.ts rename to frontend/src/api/endpoints/RecipeRestResource.ts diff --git a/frontend/src/api/endpoints/UserRestResource.ts b/frontend/src/api/endpoints/UserRestResource.ts new file mode 100644 index 0000000..02d0695 --- /dev/null +++ b/frontend/src/api/endpoints/UserRestResource.ts @@ -0,0 +1,26 @@ +import {apiClient} from "../apiClient"; +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/all"); +} + +export async function createUser(dto: CreateUserRequest): 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: ChangeUserPasswordRequest) { + return apiClient.post("/user/change-password", dto); +} 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/api/points/AuthPoint.ts b/frontend/src/api/points/AuthPoint.ts deleted file mode 100644 index bf34abd..0000000 --- a/frontend/src/api/points/AuthPoint.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { LoginRequestDto } from "../dtos/LoginRequestDto"; -import type { LoginResponseDto } from "../dtos/LoginResponseDto"; -import { postJson } from "../utils/requests"; - - -/** - * Util for handling the recipe api - */ -// read base url from .env file -const BASE_URL = import.meta.env.VITE_API_BASE; - -/** - * URL for handling recipes - */ -const AUTH_URL = `${BASE_URL}/auth` - - -/** - * Create new Recipe - * @param recipe Recipe to create - * @returns Saved recipe - */ -export async function login(requestDto: LoginRequestDto): Promise { - const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(requestDto), false); - return res.json(); -} diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx index 9f6f054..d860fbc 100644 --- a/frontend/src/components/LoginPage.tsx +++ b/frontend/src/components/LoginPage.tsx @@ -1,8 +1,8 @@ import {useState} from "react"; import Button from "./basics/Button"; -import type {LoginRequestDto} from "../api/dtos/LoginRequestDto"; -import type {LoginResponseDto} from "../api/dtos/LoginResponseDto"; -import {login} from "../api/points/AuthPoint"; +import type {LoginRequest} from "../api/dtos/LoginRequest.ts"; +import type {LoginResponse} from "../api/dtos/LoginResponse.ts"; +import {login} from "../api/endpoints/AuthRestResource.ts"; import {getRecipeListUrl} from "../routes"; import {useNavigate} from "react-router-dom"; import PasswordField from "./basics/PasswordField"; @@ -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 new file mode 100644 index 0000000..2a9d825 --- /dev/null +++ b/frontend/src/components/SettingsMenu.tsx @@ -0,0 +1,69 @@ +import {X} from "lucide-react"; +import {useNavigate} from "react-router-dom"; +import type {LoginResponse} from "../api/dtos/LoginResponse.ts"; +import {getUserUrl} from "../routes.ts"; +import Button from "./basics/Button.tsx"; +import {ButtonType} from "./basics/BasicButtonDefinitions.ts"; +import type {UserDto} from "../api/dtos/UserDto.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: LoginResponse | null = storedSession + ? JSON.parse(storedSession) + : null; + const user = loginData?.userData; + + const formatUserName = (user: UserDto) => { + const parts = []; + if (user.firstName) parts.push(user.firstName); + if (user.lastName) parts.push(user.lastName); + return parts.length > 0 ? parts.join(" ") : user.userName; + }; + + return ( +
+
+ {/* Close button */} + + +

Einstellungen

+ + {user ? ( + <> +

+ {formatUserName(user)} +

+

+ {user.role === "admin" ? "Administrator" : "Benutzer"} +

+ +
+
+ ); +} diff --git a/frontend/src/components/basics/BasicButtonDefinitions.ts b/frontend/src/components/basics/BasicButtonDefinitions.ts index 1c4a678..a5c6895 100644 --- a/frontend/src/components/basics/BasicButtonDefinitions.ts +++ b/frontend/src/components/basics/BasicButtonDefinitions.ts @@ -22,6 +22,10 @@ export const ButtonType = { textColor: "text-white", backgroundColor: "bg-gray-600 hover:bg-gray-800", }, + LightButton: { + textColor: "text-gray-600", + backgroundColor: "bg-white hover:bg-gray-300", + }, PrimaryButton: { textColor: "text-gray-600", backgroundColor: "bg-blue-300 hover:bg-blue-400", diff --git a/frontend/src/components/basics/ButtonGroupLayout.tsx b/frontend/src/components/basics/ButtonGroupLayout.tsx index 30bb688..0278d1a 100644 --- a/frontend/src/components/basics/ButtonGroupLayout.tsx +++ b/frontend/src/components/basics/ButtonGroupLayout.tsx @@ -18,7 +18,7 @@ export default function ButtonGroupLayout({children, className, ...rest}: Button
diff --git a/frontend/src/components/basics/CircularIconButton.tsx b/frontend/src/components/basics/CircularIconButton.tsx index 3089235..e6ca8c4 100644 --- a/frontend/src/components/basics/CircularIconButton.tsx +++ b/frontend/src/components/basics/CircularIconButton.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; type CircularIconButtonProps = { icon: LucideIcon; onClick: () => void; + buttonType?: ButtonType disabled?: boolean; className?: string; }; @@ -17,14 +18,16 @@ export default function CircularIconButton({ onClick, icon: Icon, className = "", + buttonType = ButtonType.PrimaryButton, disabled = false, ...props }: CircularIconButtonProps) { return ( +
+ +

{message}

+ +
+ +
+ + + ); +} \ No newline at end of file diff --git a/frontend/src/components/basics/NumberCircle.tsx b/frontend/src/components/basics/NumberCircle.tsx index 72cfb6e..d3c601b 100644 --- a/frontend/src/components/basics/NumberCircle.tsx +++ b/frontend/src/components/basics/NumberCircle.tsx @@ -12,18 +12,18 @@ type NumberCircleProps = React.HTMLAttributes & { * Can be used as a standalone indicator or as a drag handle. * Accepts any div attributes (e.g. `className`, `title`, `onClick`, or DnD listeners). */ -export function NumberCircle({ number, className, ...rest }: NumberCircleProps) { +export function NumberCircle({number, className, ...rest}: NumberCircleProps) { return (
- {number} -
-); + className={clsx( + "flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white", + "flex items-center justify-center shadow-sm", + "cursor-default select-none", + className + )} + > + {number} + + ); } diff --git a/frontend/src/components/basics/NumberStepControl.tsx b/frontend/src/components/basics/NumberStepControl.tsx index 86cde69..71f269e 100644 --- a/frontend/src/components/basics/NumberStepControl.tsx +++ b/frontend/src/components/basics/NumberStepControl.tsx @@ -50,6 +50,7 @@ export function NumberStepControl({ className)}> @@ -64,6 +65,7 @@ export function NumberStepControl({ diff --git a/frontend/src/components/basics/PageContentLayout.tsx b/frontend/src/components/basics/PageContentLayout.tsx new file mode 100644 index 0000000..861a5ac --- /dev/null +++ b/frontend/src/components/basics/PageContentLayout.tsx @@ -0,0 +1,50 @@ +// 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"; +import CircularIconButton from "./CircularIconButton.tsx"; +import {ButtonType} from "./BasicButtonDefinitions.ts"; + +/** + * 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) - Sticky with higher z-index than header */} + setIsMenuOpen(true)} + buttonType={ButtonType.LightButton} + className="absolute top-4 right-4 w-10 h-10 z-20" + icon={Settings} + /> + + {/* Page content with top padding to accommodate settings button */} +
+ {children} +
+ + {/* Overlay Settings Menu */} + {isMenuOpen && setIsMenuOpen(false)}/>} +
+ ); +} \ No newline at end of file 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/basics/SelectField.tsx b/frontend/src/components/basics/SelectField.tsx new file mode 100644 index 0000000..e8c560a --- /dev/null +++ b/frontend/src/components/basics/SelectField.tsx @@ -0,0 +1,47 @@ +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) { + 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/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) => ( {/* Container defining the maximum width of the content */} - + {/* Header - remains in position when scrolling */}

{recipeWorkingCopy.title}

@@ -123,6 +123,7 @@ export default function RecipeDetailPage() {
    {recipeWorkingCopy.ingredientGroupList.map((group, i) => ( @@ -150,7 +151,7 @@ export default function RecipeDetailPage() { /> - + ) } diff --git a/frontend/src/components/recipes/RecipeEditor.tsx b/frontend/src/components/recipes/RecipeEditor.tsx index 08f9118..154bf6c 100644 --- a/frontend/src/components/recipes/RecipeEditor.tsx +++ b/frontend/src/components/recipes/RecipeEditor.tsx @@ -7,9 +7,9 @@ import {InstructionStepListEditor} from "./InstructionStepListEditor" import type {InstructionStepModel} from "../../models/InstructionStepModel" import {ButtonType} from "../basics/BasicButtonDefinitions" 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 PageContentLayout from "../basics/PageContentLayout.tsx"; type RecipeEditorProps = { recipe: RecipeModel @@ -86,7 +86,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { /*Container spanning entire screen used to center content horizontally */ {/* Container defining the maximum width of the content */} - +

    {recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}

    @@ -152,7 +152,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { /> -
    +
    ) } diff --git a/frontend/src/components/recipes/RecipeListPage.tsx b/frontend/src/components/recipes/RecipeListPage.tsx index 765d7d5..2dab8dd 100644 --- a/frontend/src/components/recipes/RecipeListPage.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -6,8 +6,8 @@ import {useNavigate} from "react-router-dom" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import RecipeListToolbar from "./RecipeListToolbar" import StickyHeader from "../basics/StickyHeader.tsx"; -import ContentBackground from "../basics/ContentBackground.tsx"; import PageContainer from "../basics/PageContainer.tsx"; +import PageContentLayout from "../basics/PageContentLayout.tsx"; /** * Displays a list of recipes in a sidebar layout. @@ -46,7 +46,7 @@ export default function RecipeListPage() { /*Container spanning entire screen used to center content horizontally */ {/* Container defining the maximum width of the content */} - + {/* Header - remains in position when scrolling */}

    Recipes

    @@ -69,7 +69,7 @@ export default function RecipeListPage() { ))} -
    +
    ) } diff --git a/frontend/src/components/users/ChangePasswordModal.tsx b/frontend/src/components/users/ChangePasswordModal.tsx new file mode 100644 index 0000000..b480f10 --- /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/endpoints/UserRestResource.ts"; +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/UserEditForm.tsx b/frontend/src/components/users/UserEditForm.tsx new file mode 100644 index 0000000..06b0c4f --- /dev/null +++ b/frontend/src/components/users/UserEditForm.tsx @@ -0,0 +1,175 @@ +import {useEffect, 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"; +import {UserRole, UserRoleHelper} from "../../api/enums/UserRole.ts"; + +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(""); + + /** + * React to changes in selected user. + * + * When a new user is selected, the edit user must be updated and password fields as well as error messege + * must be reset. + */ + useEffect(() => { + setEditedUser({...user}); + setPassword(""); + setConfirmPassword(""); + setPasswordError(""); + }, [user]); + /** + * Calls on save after validating passwords for new user. + */ + 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); + } + }; + + /** + * All available roles + */ + const roleOptions = UserRoleHelper.getRoleOptions(); + + 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 && ( + <> + + + value={editedUser.role ?? UserRole.USER} + onChange={(value) => + 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..3e6b8df --- /dev/null +++ b/frontend/src/components/users/UserList.tsx @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..82690df --- /dev/null +++ b/frontend/src/components/users/UserManagementPage.tsx @@ -0,0 +1,217 @@ +import {useEffect, useState} from "react"; +import {createUser, fetchAllUsers, fetchCurrentUser, updateUser,} from "../../api/endpoints/UserRestResource.ts"; +import type {UserDto} from "../../api/dtos/UserDto"; +import ContentBody from "../basics/ContentBody"; +import StickyHeader from "../basics/StickyHeader"; +import Button from "../basics/Button"; +import {ButtonType} from "../basics/BasicButtonDefinitions"; +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"; +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"; +import {UserRole} from "../../api/enums/UserRole.ts"; +import PageContentLayout from "../basics/PageContentLayout.tsx"; + +/** + * 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 [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const isAdmin = currentUser?.role === UserRole.ADMIN; + + // Load current user and user list (if admin) + useEffect(() => { + loadData(); + }, []); + + /** + * Load data for user management page + * + * An admin can see all users while a normal user can only see his own user data. + * Initially, the current user is selected. + */ + const loadData = async () => { + try { + const me = await fetchCurrentUser(); + setCurrentUser(me); + + if (me.role === UserRole.ADMIN) { + const userResponse: UserListResponse = await fetchAllUsers(); + + // 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); + setSelectedUser(me); + } else { + setUsers([me]); + setSelectedUser(me); + } + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Fehler beim Laden der Benutzerdaten" + ); + } + }; + + /** Handles selecting a user from the list */ + const handleSelectUser = (user: UserDto) => { + setSelectedUser({...user}); + }; + + /** Handles saving user (create or update) */ + const handleSave = async (user: UserDto, password?: string) => { + setIsSaving(true); + setError(null); + try { + if (!user.id) { + // New user + const response: CreateUserResponse = await createUser({ + userData: user, + password: password || "", + }); + const userDto = response.userData; + if (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 + 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); + } + }; + + /** Opens password modal */ + const openPasswordModal = () => { + setIsPasswordModalOpen(true); + }; + + /** Add new empty user */ + const handleAddUser = () => { + setSelectedUser({ + userName: "", + firstName: "", + lastName: "", + email: "", + role: UserRole.USER, + }); + }; + + return ( + + + +

    Benutzerverwaltung

    + + {isAdmin && ( +