Compare commits
No commits in common. "1487bb73a1560a1f3b5c8ae950cb61d60f066acd" and "09150ba3bb380ec3a00dee303c3474b8603946fc" have entirely different histories.
1487bb73a1
...
09150ba3bb
37 changed files with 103 additions and 1113 deletions
|
|
@ -1,19 +1,11 @@
|
||||||
import {BrowserRouter as Router, Route, Routes} from "react-router-dom"
|
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"
|
||||||
import RecipeDetailPage from "./components/recipes/RecipeDetailPage"
|
import RecipeDetailPage from "./components/recipes/RecipeDetailPage"
|
||||||
import RecipeEditPage from "./components/recipes/RecipeEditPage"
|
import RecipeEditPage from "./components/recipes/RecipeEditPage"
|
||||||
import RecipeListPage from "./components/recipes/RecipeListPage"
|
import RecipeListPage from "./components/recipes/RecipeListPage"
|
||||||
import {
|
import { getLoginUrl, getRecipeAddUrlDefinition, getRecipeDetailsUrlDefinition, getRecipeEditUrlDefinition, getRecipeListUrlDefinition, getRootUrlDefinition } from "./routes"
|
||||||
getLoginUrlDefinition,
|
|
||||||
getRecipeAddUrlDefinition,
|
|
||||||
getRecipeDetailsUrlDefinition,
|
|
||||||
getRecipeEditUrlDefinition,
|
|
||||||
getRecipeListUrlDefinition,
|
|
||||||
getUserUrlDefinition
|
|
||||||
} from "./routes"
|
|
||||||
|
|
||||||
import "./App.css"
|
import "./App.css"
|
||||||
import LoginPage from "./components/LoginPage"
|
import LoginPage from "./components/LoginPage"
|
||||||
import UserManagementPage from "./components/users/UserManagementPage.tsx";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application component.
|
* Main application component.
|
||||||
|
|
@ -24,8 +16,7 @@ function App() {
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Login page */}
|
{/* Login page */}
|
||||||
<Route path={getLoginUrlDefinition()} element={<LoginPage/>}/>
|
<Route path={getLoginUrl()} element={<LoginPage/>}/>
|
||||||
|
|
||||||
{/* Home page: list of recipes */}
|
{/* Home page: list of recipes */}
|
||||||
<Route path= {getRecipeListUrlDefinition()} element={<RecipeListPage />} />
|
<Route path= {getRecipeListUrlDefinition()} element={<RecipeListPage />} />
|
||||||
|
|
||||||
|
|
@ -37,8 +28,6 @@ function App() {
|
||||||
|
|
||||||
{/* Add page: form to add a recipe */}
|
{/* Add page: form to add a recipe */}
|
||||||
<Route path={getRecipeAddUrlDefinition()} element={<RecipeEditPage />} />
|
<Route path={getRecipeAddUrlDefinition()} element={<RecipeEditPage />} />
|
||||||
{/*User management */}
|
|
||||||
<Route path={getUserUrlDefinition()} element={<UserManagementPage/>}/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
/**
|
|
||||||
* 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"}),
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/**
|
|
||||||
* DTO for changing user password
|
|
||||||
*/
|
|
||||||
export class ChangeUserPasswordRequest {
|
|
||||||
userId?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import type {UserDto} from "./UserDto.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO used for user creation
|
|
||||||
*/
|
|
||||||
export class CreateUserRequest {
|
|
||||||
userData?: UserDto;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { UserDto } from "./UserDto.js";
|
|
||||||
|
|
||||||
export class CreateUserResponse{
|
|
||||||
userData?: UserDto;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Defines a login request
|
* Defines a login request
|
||||||
*/
|
*/
|
||||||
export class LoginRequest {
|
export class LoginRequestDto {
|
||||||
userName?: string;
|
userName?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import {UserDto} from "./UserDto.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response to a successful login
|
|
||||||
*/
|
|
||||||
export class LoginResponse {
|
|
||||||
userData?: UserDto;
|
|
||||||
token?: string;
|
|
||||||
expiryDate?: Date;
|
|
||||||
}
|
|
||||||
10
frontend/src/api/dtos/LoginResponseDto.ts
Normal file
10
frontend/src/api/dtos/LoginResponseDto.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { UserDto } from "./UserDto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response to a successful login
|
||||||
|
*/
|
||||||
|
export class LoginResponseDto {
|
||||||
|
userData?: UserDto;
|
||||||
|
token?: string;
|
||||||
|
expiryDate? : Date;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import {AbstractDto} from "./AbstractDto";
|
import { AbstractDto } from "./AbstractDto.ts";
|
||||||
import {UserRole} from "../enums/UserRole";
|
|
||||||
|
|
||||||
export interface UserDto extends AbstractDto {
|
export class UserDto extends AbstractDto {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
userName: string;
|
userName!: string;
|
||||||
email: string;
|
email!: string;
|
||||||
role?: UserRole;
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import {UserDto} from "./UserDto.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API response for delivering a list of users
|
|
||||||
*/
|
|
||||||
export class UserListResponse {
|
|
||||||
valueList: UserDto[] = [];
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
26
frontend/src/api/points/AuthPoint.ts
Normal file
26
frontend/src/api/points/AuthPoint.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Button from "./basics/Button";
|
import Button from "./basics/Button";
|
||||||
import type {LoginRequest} from "../api/dtos/LoginRequest.ts";
|
import type {LoginRequestDto} from "../api/dtos/LoginRequestDto";
|
||||||
import type {LoginResponse} from "../api/dtos/LoginResponse.ts";
|
import type {LoginResponseDto} from "../api/dtos/LoginResponseDto";
|
||||||
import {login} from "../api/endpoints/AuthRestResource.ts";
|
import {login} from "../api/points/AuthPoint";
|
||||||
import {getRecipeListUrl} from "../routes";
|
import {getRecipeListUrl} from "../routes";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import PasswordField from "./basics/PasswordField";
|
import PasswordField from "./basics/PasswordField";
|
||||||
|
|
@ -20,14 +20,14 @@ export default function LoginPage() {
|
||||||
* Login
|
* Login
|
||||||
*/
|
*/
|
||||||
const executeLogin = async () => {
|
const executeLogin = async () => {
|
||||||
const dto: LoginRequest = {
|
const dto: LoginRequestDto = {
|
||||||
userName,
|
userName,
|
||||||
password,
|
password,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Trying to log in with " + dto.userName);
|
console.log("Trying to log in with " + dto.userName);
|
||||||
const loginResponse: LoginResponse = await login(dto);
|
const loginResponse: LoginResponseDto = await login(dto);
|
||||||
localStorage.setItem("session", JSON.stringify(loginResponse));
|
localStorage.setItem("session", JSON.stringify(loginResponse));
|
||||||
console.log("Successfully logged in as " + loginResponse.userData?.userName);
|
console.log("Successfully logged in as " + loginResponse.userData?.userName);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -22,10 +22,6 @@ export const ButtonType = {
|
||||||
textColor: "text-white",
|
textColor: "text-white",
|
||||||
backgroundColor: "bg-gray-600 hover:bg-gray-800",
|
backgroundColor: "bg-gray-600 hover:bg-gray-800",
|
||||||
},
|
},
|
||||||
LightButton: {
|
|
||||||
textColor: "text-gray-600",
|
|
||||||
backgroundColor: "bg-white hover:bg-gray-300",
|
|
||||||
},
|
|
||||||
PrimaryButton: {
|
PrimaryButton: {
|
||||||
textColor: "text-gray-600",
|
textColor: "text-gray-600",
|
||||||
backgroundColor: "bg-blue-300 hover:bg-blue-400",
|
backgroundColor: "bg-blue-300 hover:bg-blue-400",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default function ButtonGroupLayout({children, className, ...rest}: Button
|
||||||
<div
|
<div
|
||||||
{...rest}
|
{...rest}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex gap-4 mt-4",
|
"flex gap-4 mt-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import clsx from "clsx";
|
||||||
type CircularIconButtonProps = {
|
type CircularIconButtonProps = {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
buttonType?: ButtonType
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -18,16 +17,14 @@ export default function CircularIconButton({
|
||||||
onClick,
|
onClick,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
className = "",
|
className = "",
|
||||||
buttonType = ButtonType.PrimaryButton,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
...props
|
...props
|
||||||
}: CircularIconButtonProps) {
|
}: CircularIconButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center shadow-sm",
|
"flex-shrink-0 w-7 h-7 rounded-full text-white flex items-center justify-center shadow-sm",
|
||||||
buttonType.backgroundColor,
|
ButtonType.PrimaryButton.backgroundColor,
|
||||||
buttonType?.textColor,
|
|
||||||
className)}
|
className)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,6 @@ export function NumberStepControl({
|
||||||
className)}>
|
className)}>
|
||||||
<CircularIconButton
|
<CircularIconButton
|
||||||
onClick={handleDecrease}
|
onClick={handleDecrease}
|
||||||
className={"text-white"}
|
|
||||||
icon={Minus}
|
icon={Minus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -65,7 +64,6 @@ export function NumberStepControl({
|
||||||
|
|
||||||
<CircularIconButton
|
<CircularIconButton
|
||||||
onClick={handleIncrease}
|
onClick={handleIncrease}
|
||||||
className={"text-white"}
|
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,18 +7,12 @@ type PasswordFieldProps = {
|
||||||
onPasswordChanged: (password: string) => void
|
onPasswordChanged: (password: string) => void
|
||||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
|
||||||
className?: string
|
className?: string
|
||||||
placeholder?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Password field component
|
* Password field component
|
||||||
*/
|
*/
|
||||||
export default function PasswordField({
|
export default function PasswordField({onPasswordChanged, onKeyDown, className = ""}: PasswordFieldProps) {
|
||||||
onPasswordChanged,
|
|
||||||
onKeyDown,
|
|
||||||
placeholder = "Passwort",
|
|
||||||
className = ""
|
|
||||||
}: PasswordFieldProps) {
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [password, setPassword] = useState<string>("");
|
const [password, setPassword] = useState<string>("");
|
||||||
|
|
||||||
|
|
@ -33,7 +27,7 @@ export default function PasswordField({
|
||||||
<input
|
<input
|
||||||
className="pr-10"
|
className="pr-10"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder={placeholder}
|
placeholder="Passwort"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => changePassword(e.target.value)}
|
onChange={(e) => changePassword(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,6 @@ export function IngredientGroupListEditor({ingredientGroupList, onChange}: Ingre
|
||||||
{ingredientGroupList.map((ingGrp, index) => (
|
{ingredientGroupList.map((ingGrp, index) => (
|
||||||
<IngredientGroupEditorListItem
|
<IngredientGroupEditorListItem
|
||||||
index={index}
|
index={index}
|
||||||
key={index}
|
|
||||||
ingredientGroupModel={ingGrp}
|
ingredientGroupModel={ingGrp}
|
||||||
handleUpdate={handleUpdate}
|
handleUpdate={handleUpdate}
|
||||||
handleRemove={handleRemove}
|
handleRemove={handleRemove}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {useParams} from "react-router-dom"
|
import {useParams} from "react-router-dom"
|
||||||
import type {RecipeModel} from "../../models/RecipeModel"
|
import type {RecipeModel} from "../../models/RecipeModel"
|
||||||
import {useEffect, useState} from "react"
|
import {useEffect, useState} from "react"
|
||||||
import {fetchRecipe} from "../../api/endpoints/RecipeRestResource.ts"
|
import {fetchRecipe} from "../../api/points/RecipePoint"
|
||||||
import {getRecipeEditUrl, getRecipeListUrl} from "../../routes"
|
import {getRecipeEditUrl, getRecipeListUrl} from "../../routes"
|
||||||
import ButtonLink from "../basics/ButtonLink"
|
import ButtonLink from "../basics/ButtonLink"
|
||||||
import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
|
import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
|
||||||
|
|
@ -10,11 +10,11 @@ import {NumberedListItem} from "../basics/NumberedListItem.tsx";
|
||||||
import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
|
import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
|
||||||
import StickyHeader from "../basics/StickyHeader.tsx";
|
import StickyHeader from "../basics/StickyHeader.tsx";
|
||||||
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
||||||
|
import ContentBackground from "../basics/ContentBackground.tsx";
|
||||||
import ContentBody from "../basics/ContentBody.tsx";
|
import ContentBody from "../basics/ContentBody.tsx";
|
||||||
import PageContainer from "../basics/PageContainer.tsx";
|
import PageContainer from "../basics/PageContainer.tsx";
|
||||||
import {BoxContainer} from "../basics/BoxContainer.tsx";
|
import {BoxContainer} from "../basics/BoxContainer.tsx";
|
||||||
import IngredientGroupDisplayListItem from "./IngredientGroupDisplayListItem.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 spanning entire screen used to center content horizontally */
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Container defining the maximum width of the content */}
|
{/* Container defining the maximum width of the content */}
|
||||||
<PageContentLayout>
|
<ContentBackground>
|
||||||
{/* Header - remains in position when scrolling */}
|
{/* Header - remains in position when scrolling */}
|
||||||
<StickyHeader>
|
<StickyHeader>
|
||||||
<h1>{recipeWorkingCopy.title}</h1>
|
<h1>{recipeWorkingCopy.title}</h1>
|
||||||
|
|
@ -123,7 +123,6 @@ export default function RecipeDetailPage() {
|
||||||
<ul>
|
<ul>
|
||||||
{recipeWorkingCopy.ingredientGroupList.map((group, i) => (
|
{recipeWorkingCopy.ingredientGroupList.map((group, i) => (
|
||||||
<IngredientGroupDisplayListItem
|
<IngredientGroupDisplayListItem
|
||||||
key={i}
|
|
||||||
index={i}
|
index={i}
|
||||||
groupModel={group}
|
groupModel={group}
|
||||||
/>
|
/>
|
||||||
|
|
@ -151,7 +150,7 @@ export default function RecipeDetailPage() {
|
||||||
/>
|
/>
|
||||||
</ButtonGroupLayout>
|
</ButtonGroupLayout>
|
||||||
</ContentBody>
|
</ContentBody>
|
||||||
</PageContentLayout>
|
</ContentBackground>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import {InstructionStepListEditor} from "./InstructionStepListEditor"
|
||||||
import type {InstructionStepModel} from "../../models/InstructionStepModel"
|
import type {InstructionStepModel} from "../../models/InstructionStepModel"
|
||||||
import {ButtonType} from "../basics/BasicButtonDefinitions"
|
import {ButtonType} from "../basics/BasicButtonDefinitions"
|
||||||
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
||||||
|
import ContentBackground from "../basics/ContentBackground.tsx";
|
||||||
import ContentBody from "../basics/ContentBody.tsx";
|
import ContentBody from "../basics/ContentBody.tsx";
|
||||||
import PageContainer from "../basics/PageContainer.tsx";
|
import PageContainer from "../basics/PageContainer.tsx";
|
||||||
import PageContentLayout from "../basics/PageContentLayout.tsx";
|
|
||||||
|
|
||||||
type RecipeEditorProps = {
|
type RecipeEditorProps = {
|
||||||
recipe: RecipeModel
|
recipe: RecipeModel
|
||||||
|
|
@ -86,7 +86,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
/*Container spanning entire screen used to center content horizontally */
|
/*Container spanning entire screen used to center content horizontally */
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Container defining the maximum width of the content */}
|
{/* Container defining the maximum width of the content */}
|
||||||
<PageContentLayout>
|
<ContentBackground>
|
||||||
<h1 className="border-b-2 border-gray-300 pb-4">
|
<h1 className="border-b-2 border-gray-300 pb-4">
|
||||||
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
|
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -152,7 +152,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
/>
|
/>
|
||||||
</ButtonGroupLayout>
|
</ButtonGroupLayout>
|
||||||
</ContentBody>
|
</ContentBody>
|
||||||
</PageContentLayout>
|
</ContentBackground>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import {useNavigate} from "react-router-dom"
|
||||||
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
||||||
import RecipeListToolbar from "./RecipeListToolbar"
|
import RecipeListToolbar from "./RecipeListToolbar"
|
||||||
import StickyHeader from "../basics/StickyHeader.tsx";
|
import StickyHeader from "../basics/StickyHeader.tsx";
|
||||||
|
import ContentBackground from "../basics/ContentBackground.tsx";
|
||||||
import PageContainer from "../basics/PageContainer.tsx";
|
import PageContainer from "../basics/PageContainer.tsx";
|
||||||
import PageContentLayout from "../basics/PageContentLayout.tsx";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a list of recipes in a sidebar layout.
|
* 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 spanning entire screen used to center content horizontally */
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Container defining the maximum width of the content */}
|
{/* Container defining the maximum width of the content */}
|
||||||
<PageContentLayout>
|
<ContentBackground>
|
||||||
{/* Header - remains in position when scrolling */}
|
{/* Header - remains in position when scrolling */}
|
||||||
<StickyHeader>
|
<StickyHeader>
|
||||||
<h1>Recipes</h1>
|
<h1>Recipes</h1>
|
||||||
|
|
@ -69,7 +69,7 @@ export default function RecipeListPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageContentLayout>
|
</ContentBackground>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,59 +2,17 @@
|
||||||
* Routes for all pages
|
* Routes for all pages
|
||||||
*/
|
*/
|
||||||
// Route definitions using :id as placeholder for the id
|
// Route definitions using :id as placeholder for the id
|
||||||
export function getRootUrlDefinition(): string {
|
export function getRootUrlDefinition() : string { return getRootUrl()}
|
||||||
return getRootUrl()
|
export function getRecipeDetailsUrlDefinition() : string {return getRecipeDetailUrl(":id")}
|
||||||
}
|
export function getRecipeEditUrlDefinition() : string {return getRecipeEditUrl(":id")}
|
||||||
|
export function getRecipeAddUrlDefinition() : string {return getRecipeAddUrl()}
|
||||||
export function getRecipeDetailsUrlDefinition(): string {
|
export function getRecipeListUrlDefinition() : string {return getRecipeListUrl()}
|
||||||
return getRecipeDetailUrl(":id")
|
export function getLoginUrlDefinition() : string {return getLoginUrl()}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// URLs including id
|
||||||
export function getRootUrl(): string {
|
export function getRootUrl () : string { return "/"}
|
||||||
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 getRecipeListUrl(): string {
|
export function getRecipeAddUrl() : string {return "/recipe/add"}
|
||||||
return "/recipe/list"
|
export function getLoginUrl() : string {return "/login"}
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue