Compare commits

...

16 commits

Author SHA1 Message Date
araemer
1487bb73a1 Renaming of rest resources 2026-02-21 07:43:49 +01:00
araemer
e6ed2825e2 Fix margins 2026-02-21 07:13:17 +01:00
araemer
b4364ff76c Layouting 2025-12-07 08:42:56 +01:00
araemer
fdb1b3625f Use PageContentLayout.tsx everywhere 2025-12-07 08:26:05 +01:00
araemer
df39a1a217 Style and position settings button 2025-12-07 08:10:27 +01:00
araemer
5b83a783aa Format SettingsMenu.tsx 2025-12-07 07:47:21 +01:00
araemer
02fbfb033d Fix user selection 2025-12-07 07:35:22 +01:00
araemer
e6fd6d7d6f Enum for user role - somehow user page is currently unresponsive. Probably because of previous layout changes 2025-11-30 08:42:07 +01:00
araemer
bd6ee25910 Layout changes 2025-11-30 08:03:18 +01:00
araemer
e1eeef8d8a Sort user list, preselect own user, check password before saving 2025-11-30 07:36:47 +01:00
araemer
ac3450239e Add loading UserList for admin user 2025-11-20 20:16:56 +01:00
araemer
ba054ef2b9 Add missing key to list 2025-11-19 20:51:20 +01:00
araemer
c3998ca039 rename API files 2025-11-13 21:34:06 +01:00
araemer
c8b8435b69 Add components for SelectField and TextLinkButton 2025-11-13 20:37:26 +01:00
araemer
e539f9201b Styling and removing unnecessary ai-generated css 2025-11-05 20:27:45 +01:00
araemer
9e7ad622f9 add basic user management 2025-11-02 09:09:34 +01:00
37 changed files with 1113 additions and 103 deletions

View file

@ -1,11 +1,19 @@
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.
@ -16,7 +24,8 @@ function App() {
<Router>
<Routes>
{/* Login page */}
<Route path={getLoginUrl()} element={<LoginPage/>}/>
<Route path={getLoginUrlDefinition()} element={<LoginPage/>}/>
{/* Home page: list of recipes */}
<Route path={getRecipeListUrlDefinition()} element={<RecipeListPage/>}/>
@ -28,6 +37,8 @@ function App() {
{/* Add page: form to add a recipe */}
<Route path={getRecipeAddUrlDefinition()} element={<RecipeEditPage/>}/>
{/*User management */}
<Route path={getUserUrlDefinition()} element={<UserManagementPage/>}/>
</Routes>
</Router>
)

View file

@ -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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
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<T>;
}
/**
* Shorthand helpers for convenience.
*/
export const apiClient = {
get: <T>(endpoint: string) => apiRequest<T>(endpoint, {method: "GET"}),
post: <T>(endpoint: string, body: object) =>
apiRequest<T>(endpoint, {method: "POST", body: JSON.stringify(body)}),
put: <T>(endpoint: string, body: object) =>
apiRequest<T>(endpoint, {method: "PUT", body: JSON.stringify(body)}),
delete: <T>(endpoint: string) =>
apiRequest<T>(endpoint, {method: "DELETE"}),
};

View file

@ -0,0 +1,7 @@
/**
* DTO for changing user password
*/
export class ChangeUserPasswordRequest {
userId?: string;
password?: string;
}

View file

@ -0,0 +1,9 @@
import type {UserDto} from "./UserDto.js";
/**
* DTO used for user creation
*/
export class CreateUserRequest {
userData?: UserDto;
password?: string;
}

View file

@ -0,0 +1,5 @@
import { UserDto } from "./UserDto.js";
export class CreateUserResponse{
userData?: UserDto;
}

View file

@ -1,7 +1,7 @@
/**
* Defines a login request
*/
export class LoginRequestDto {
export class LoginRequest {
userName?: string;
password?: string;
}

View file

@ -0,0 +1,10 @@
import {UserDto} from "./UserDto.js";
/**
* Response to a successful login
*/
export class LoginResponse {
userData?: UserDto;
token?: string;
expiryDate?: Date;
}

View file

@ -1,10 +0,0 @@
import { UserDto } from "./UserDto.js";
/**
* Response to a successful login
*/
export class LoginResponseDto {
userData?: UserDto;
token?: string;
expiryDate? : Date;
}

View file

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

View file

@ -0,0 +1,8 @@
import {UserDto} from "./UserDto.js";
/**
* API response for delivering a list of users
*/
export class UserListResponse {
valueList: UserDto[] = [];
}

View file

@ -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<LoginResponse> {
const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(loginRequest), false);
return res.json();
}

View file

@ -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<UserDto> {
return apiClient.get("/user/me");
}
export async function fetchAllUsers(): Promise<UserListResponse> {
return apiClient.get("/user/all");
}
export async function createUser(dto: CreateUserRequest): Promise<CreateUserResponse> {
return apiClient.post("/user/create", dto);
}
export async function updateUser(dto: UserDto): Promise<UserDto> {
return apiClient.post("/user/update", dto);
}
export async function changePassword(dto: ChangeUserPasswordRequest) {
return apiClient.post("/user/change-password", dto);
}

View file

@ -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, string> = {
[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);
},
};

View file

@ -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<LoginResponseDto> {
const res = await postJson(`${AUTH_URL}/login`, JSON.stringify(requestDto), false);
return res.json();
}

View file

@ -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);

View file

@ -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 (
<div className="fixed inset-0 bg-black/40 flex justify-end z-50">
<div className="bg-white dark:bg-gray-800 w-80 p-6 shadow-xl flex flex-col">
{/* Close button */}
<button
onClick={onClose}
className="self-end text-gray-500 hover:text-gray-800"
>
<X/>
</button>
<h2 className="text-xl font-semibold mb-4">Einstellungen</h2>
{user ? (
<>
<p className="text-gray-800 dark:text-gray-100 font-medium">
{formatUserName(user)}
</p>
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
{user.role === "admin" ? "Administrator" : "Benutzer"}
</p>
<Button
onClick={() => {
navigate(getUserUrl());
onClose();
}}
buttonType={ButtonType.PrimaryButton}
text={"Benutzerverwaltung"}
/>
</>
) : (
<p className="text-gray-500">Nicht eingeloggt</p>
)}
</div>
</div>
);
}

View file

@ -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",

View file

@ -18,7 +18,7 @@ export default function ButtonGroupLayout({children, className, ...rest}: Button
<div
{...rest}
className={clsx(
"flex gap-4 mt-8",
"flex gap-4 mt-4",
className
)}
>

View file

@ -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 (
<button
className={clsx(
"flex-shrink-0 w-7 h-7 rounded-full text-white flex items-center justify-center shadow-sm",
ButtonType.PrimaryButton.backgroundColor,
"flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center shadow-sm",
buttonType.backgroundColor,
buttonType?.textColor,
className)}
onClick={onClick}
disabled={disabled}

View file

@ -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 (
<div className="fixed inset-0 flex items-center justify-center bg-black/40 z-50">
<div className="bg-white rounded-lg p-6 w-96 shadow-lg">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<AlertCircle className="text-red-600" size={24}/>
<h3 className="text-lg font-semibold text-red-600">Fehler</h3>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={20}/>
</button>
</div>
<p className="text-gray-700 mb-4">{message}</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Schließen
</button>
</div>
</div>
</div>
);
}

View file

@ -50,6 +50,7 @@ export function NumberStepControl({
className)}>
<CircularIconButton
onClick={handleDecrease}
className={"text-white"}
icon={Minus}
/>
@ -64,6 +65,7 @@ export function NumberStepControl({
<CircularIconButton
onClick={handleIncrease}
className={"text-white"}
icon={Plus}
/>

View file

@ -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<HTMLDivElement> & {
/** The main page content (e.g. header, body) */
children: ReactNode;
};
export default function PageContentLayout({
children,
className,
...props
}: PageContentLayoutProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div
{...props}
className={clsx(
"relative bg-gray-100 w-full min-h-screen max-w-6xl shadow-xl p-8",
className
)}
>
{/* Settings Button (top-right) - Sticky with higher z-index than header */}
<CircularIconButton
onClick={() => 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 */}
<div className="pt-8">
{children}
</div>
{/* Overlay Settings Menu */}
{isMenuOpen && <SettingsMenu onClose={() => setIsMenuOpen(false)}/>}
</div>
);
}

View file

@ -7,12 +7,18 @@ type PasswordFieldProps = {
onPasswordChanged: (password: string) => void
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => 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<string>("");
@ -27,7 +33,7 @@ export default function PasswordField({onPasswordChanged, onKeyDown, className =
<input
className="pr-10"
type={showPassword ? "text" : "password"}
placeholder="Passwort"
placeholder={placeholder}
value={password}
onChange={(e) => changePassword(e.target.value)}
onKeyDown={onKeyDown}

View file

@ -0,0 +1,47 @@
type SelectFieldProps<T extends string> = {
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
* <SelectField<UserRole>
* value={user.role}
* onChange={(role) => setUser({...user, role})}
* options={UserRoleHelper.getRoleOptions()}
* />
*
* @example
* // With plain strings
* <SelectField<string>
* value={status}
* onChange={setStatus}
* options={[{value: "active", label: "Active"}, {value: "inactive", label: "Inactive"}]}
* />
*/
export default function SelectField<T extends string>({
value,
onChange,
options,
className = ''
}: SelectFieldProps<T>) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as T)}
className={`p-2 w-full border rounded-md bg-white border-gray-600 hover:border-blue-800 transition-colors text-gray-600 focus:outline-none focus:border-blue-900 cursor-pointer ${className}`}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}

View file

@ -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 (
<button
type="button"
onClick={onClick}
className={`text-blue-600 hover:text-blue-800 hover:underline transition-colors bg-transparent border-none p-0 cursor-pointer ${className}`}
>
{text}
</button>
);
}

View file

@ -50,6 +50,7 @@ export function IngredientGroupListEditor({ingredientGroupList, onChange}: Ingre
{ingredientGroupList.map((ingGrp, index) => (
<IngredientGroupEditorListItem
index={index}
key={index}
ingredientGroupModel={ingGrp}
handleUpdate={handleUpdate}
handleRemove={handleRemove}

View file

@ -1,7 +1,7 @@
import {useParams} from "react-router-dom"
import type {RecipeModel} from "../../models/RecipeModel"
import {useEffect, useState} from "react"
import {fetchRecipe} from "../../api/points/RecipePoint"
import {fetchRecipe} from "../../api/endpoints/RecipeRestResource.ts"
import {getRecipeEditUrl, getRecipeListUrl} from "../../routes"
import ButtonLink from "../basics/ButtonLink"
import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
@ -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 */
<PageContainer>
{/* Container defining the maximum width of the content */}
<ContentBackground>
<PageContentLayout>
{/* Header - remains in position when scrolling */}
<StickyHeader>
<h1>{recipeWorkingCopy.title}</h1>
@ -123,6 +123,7 @@ export default function RecipeDetailPage() {
<ul>
{recipeWorkingCopy.ingredientGroupList.map((group, i) => (
<IngredientGroupDisplayListItem
key={i}
index={i}
groupModel={group}
/>
@ -150,7 +151,7 @@ export default function RecipeDetailPage() {
/>
</ButtonGroupLayout>
</ContentBody>
</ContentBackground>
</PageContentLayout>
</PageContainer>
)
}

View file

@ -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 */
<PageContainer>
{/* Container defining the maximum width of the content */}
<ContentBackground>
<PageContentLayout>
<h1 className="border-b-2 border-gray-300 pb-4">
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
</h1>
@ -152,7 +152,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
/>
</ButtonGroupLayout>
</ContentBody>
</ContentBackground>
</PageContentLayout>
</PageContainer>
)
}

View file

@ -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 */
<PageContainer>
{/* Container defining the maximum width of the content */}
<ContentBackground>
<PageContentLayout>
{/* Header - remains in position when scrolling */}
<StickyHeader>
<h1>Recipes</h1>
@ -69,7 +69,7 @@ export default function RecipeListPage() {
))}
</div>
</div>
</ContentBackground>
</PageContentLayout>
</PageContainer>
)
}

View file

@ -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 (
<div className="fixed inset-0 flex items-center justify-center bg-black/40 z-50">
<div className="bg-white rounded-lg p-6 w-90 shadow-lg">
<h3 className="text-lg font-semibold mb-4">Passwort ändern</h3>
<PasswordField
className="mb-2"
placeholder="Neues Passwort"
onPasswordChanged={setPassword}
onKeyDown={handleSave}
/>
<PasswordField
className="mb-2"
placeholder="Passwort bestätigen"
onPasswordChanged={setConfirm}
onKeyDown={handleSave}
/>
{error && <p className="error-text mb-2">{error}</p>}
<ButtonGroupLayout>
<Button
onClick={handleSave}
buttonType={ButtonType.PrimaryButton}
text="Passwort ändern"
/>
<Button
onClick={onClose}
text="Abbrechen"
/>
</ButtonGroupLayout>
</div>
</div>
);
}

View file

@ -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<void>;
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<UserDto>({...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 (
<div className="px-6 py-2">
<h2>{editedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"}</h2>
<div className="flex flex-col gap-3 max-w-md">
<label>Benutzername</label>
<input
type="text"
placeholder="Benutzername"
value={editedUser.userName}
onChange={(e) =>
setEditedUser({...editedUser, userName: e.target.value})
}
/>
<label>Vorname</label>
<input
type="text"
placeholder="Vorname"
value={editedUser.firstName ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, firstName: e.target.value})
}
/>
<label>Nachname</label>
<input
type="text"
placeholder="Nachname"
value={editedUser.lastName ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, lastName: e.target.value})
}
/>
<label>E-Mail</label>
<input
type="email"
placeholder="E-Mail"
value={editedUser.email ?? ""}
onChange={(e) =>
setEditedUser({...editedUser, email: e.target.value})
}
/>
{isAdmin && (
<>
<label>Benutzergruppe</label>
<SelectField<UserRole>
value={editedUser.role ?? UserRole.USER}
onChange={(value) =>
setEditedUser({...editedUser, role: value})
}
options={roleOptions}
/>
</>
)}
{/* Show password field only when creating new user */}
{!editedUser.id && (
<>
<label>Passwort</label>
<PasswordField
onPasswordChanged={setPassword}
onKeyDown={() => {
}}
/>
<label>Passwort bestätigen</label>
<PasswordField
placeholder="Passwort bestätigen"
onPasswordChanged={setConfirmPassword}
onKeyDown={() => {
}}
/>
{passwordError && <p className="error-text">{passwordError}</p>}
</>
)}
{/* Change password link for existing user */}
{editedUser.id && (
<div className="mt-2 text-right">
<TextLinkButton
text="Passwort ändern"
onClick={onOpenPasswordModal}
/>
</div>
)}
<ButtonGroupLayout>
<Button
text={isSaving ? "Speichern..." : "Speichern"}
onClick={handleSave}
buttonType={ButtonType.PrimaryButton}
disabled={isSaving}
/>
<Button text="Abbrechen" onClick={onCancel} disabled={isSaving}/>
</ButtonGroupLayout>
</div>
</div>
);
}

View file

@ -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 (
<div className="border-r border-gray-300 h-full">
<h2 className="px-4 py-2">Benutzer</h2>
<ul>
{users.map((user) => (
<li
key={user.id ?? user.userName}
className={clsx(
"px-4 py-3 cursor-pointer hover:bg-gray-200 transition-colors border-b border-gray-300",
selectedUser?.id === user.id && "bg-blue-100 hover:bg-blue-200"
)}
onClick={() => {
onSelectUser(user)
}}
>
<div className={clsx(
"text-base",
selectedUser?.id === user.id && "font-semibold"
)}>
{formatUserName(user)}
</div>
<div className="text-sm text-gray-600 mt-1">
{user.userName}
</div>
</li>
))}
</ul>
</div>
);
}

View file

@ -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<UserDto[]>([]);
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null);
const [currentUser, setCurrentUser] = useState<UserDto | null>(null);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<PageContainer>
<PageContentLayout>
<StickyHeader className="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<h1>Benutzerverwaltung</h1>
<ButtonGroupLayout>
{isAdmin && (
<Button
icon={Plus}
onClick={handleAddUser}
text="Neuer Benutzer"
buttonType={ButtonType.PrimaryButton}
/>
)}
{/* Leave user management and return to recipe list. @todo handle unsaved changes?*/}
<ButtonLink icon={X} to={getRecipeListUrl()}/>
</ButtonGroupLayout>
</StickyHeader>
<ContentBody className="p-0">
<div className="flex flex-col md:flex-row min-h-[500px]">
{/* User List - Left side, no padding */}
<div className="md:w-2/5 lg:w-1/3">
<UserList
users={users}
selectedUser={selectedUser}
onSelectUser={handleSelectUser}
/>
</div>
{/* Edit Form - Right side, takes remaining space */}
<div className="md:w-3/5 lg:w-2/3">
{selectedUser ? (
<UserEditForm
user={selectedUser}
isAdmin={isAdmin}
isSaving={isSaving}
onSave={handleSave}
onCancel={loadData}
onOpenPasswordModal={openPasswordModal}
/>
) : (
<div className="px-6 py-4">
<p className="text-gray-600">Bitte einen Benutzer auswählen.</p>
</div>
)}
</div>
</div>
</ContentBody>
{/* Password Change Modal */}
{isPasswordModalOpen && (
<ChangePasswordModal
userId={selectedUser?.id}
onClose={() => setIsPasswordModalOpen(false)}
/>
)}
{/* Error Popup */}
{error && (
<ErrorPopup message={error} onClose={() => setError(null)}/>
)}
</PageContentLayout>
</PageContainer>
);
}

View file

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