add basic user management
This commit is contained in:
parent
09150ba3bb
commit
9e7ad622f9
12 changed files with 673 additions and 35 deletions
|
|
@ -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 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 { getLoginUrl, getRecipeAddUrlDefinition, getRecipeDetailsUrlDefinition, getRecipeEditUrlDefinition, getRecipeListUrlDefinition, getRootUrlDefinition } from "./routes"
|
import {
|
||||||
|
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.
|
||||||
|
|
@ -16,7 +24,8 @@ function App() {
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Login page */}
|
{/* Login page */}
|
||||||
<Route path={getLoginUrl()} element={<LoginPage/>}/>
|
<Route path={getLoginUrlDefinition()} element={<LoginPage/>}/>
|
||||||
|
|
||||||
{/* Home page: list of recipes */}
|
{/* Home page: list of recipes */}
|
||||||
<Route path={getRecipeListUrlDefinition()} element={<RecipeListPage/>}/>
|
<Route path={getRecipeListUrlDefinition()} element={<RecipeListPage/>}/>
|
||||||
|
|
||||||
|
|
@ -28,6 +37,8 @@ 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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
97
frontend/src/api/apiClient.ts
Normal file
97
frontend/src/api/apiClient.ts
Normal 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"}),
|
||||||
|
};
|
||||||
7
frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts
Normal file
7
frontend/src/api/dtos/ChangeUserPasswordRequestDto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* DTO for changing user password
|
||||||
|
*/
|
||||||
|
export class ChangeUserPasswordRequestDto {
|
||||||
|
userId?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
9
frontend/src/api/dtos/CreateUserRequestDto.ts
Normal file
9
frontend/src/api/dtos/CreateUserRequestDto.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { UserDto } from "./UserDto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO used for user creation
|
||||||
|
*/
|
||||||
|
export class CreateUserRequestDto {
|
||||||
|
userData?: UserDto;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
24
frontend/src/api/points/UserPoint.ts
Normal file
24
frontend/src/api/points/UserPoint.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import {apiClient} from "../apiClient";
|
||||||
|
import type {UserDto} from "../dtos/UserDto";
|
||||||
|
import type {CreateUserRequestDto} from "../dtos/CreateUserRequestDto";
|
||||||
|
import type {ChangeUserPasswordRequestDto} from "../dtos/ChangeUserPasswordRequestDto";
|
||||||
|
|
||||||
|
export async function fetchCurrentUser(): Promise<UserDto> {
|
||||||
|
return apiClient.get("/user/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllUsers(): Promise<UserDto[]> {
|
||||||
|
return apiClient.get("/user/list");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(dto: CreateUserRequestDto): Promise<UserDto> {
|
||||||
|
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: ChangeUserPasswordRequestDto) {
|
||||||
|
return apiClient.post("/user/change-password", dto);
|
||||||
|
}
|
||||||
61
frontend/src/components/SettingsMenu.tsx
Normal file
61
frontend/src/components/SettingsMenu.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// src/components/basics/SettingsMenu.tsx
|
||||||
|
import {X} from "lucide-react";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import type {LoginResponseDto} from "../api/dtos/LoginResponseDto";
|
||||||
|
import {getUserUrl} from "../routes.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay settings menu that displays current user info
|
||||||
|
* and provides navigation to user management.
|
||||||
|
*/
|
||||||
|
type SettingsMenuProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsMenu({onClose}: SettingsMenuProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const storedSession = localStorage.getItem("session");
|
||||||
|
const loginData: LoginResponseDto | null = storedSession
|
||||||
|
? JSON.parse(storedSession)
|
||||||
|
: null;
|
||||||
|
const user = loginData?.userData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</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();
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||||
|
>
|
||||||
|
Benutzerverwaltung
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500">Nicht eingeloggt</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/components/basics/PageContentLayout.tsx
Normal file
47
frontend/src/components/basics/PageContentLayout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// src/components/basics/PageContentLayout.tsx
|
||||||
|
import React, {type ReactNode, useState} from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {Settings} from "lucide-react";
|
||||||
|
import {SettingsMenu} from "../SettingsMenu";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout wrapper for the main content area of a page.
|
||||||
|
* Includes a settings button (top-right) that opens a global settings menu.
|
||||||
|
*/
|
||||||
|
type PageContentLayoutProps = React.HTMLAttributes<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) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(true)}
|
||||||
|
className="absolute top-4 right-4 p-2 rounded-full bg-white hover:bg-gray-200 shadow transition"
|
||||||
|
title="Einstellungen"
|
||||||
|
>
|
||||||
|
<Settings size={20}/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Overlay Settings Menu */}
|
||||||
|
{isMenuOpen && <SettingsMenu onClose={() => setIsMenuOpen(false)}/>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,12 +7,18 @@ 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({onPasswordChanged, onKeyDown, className = ""}: PasswordFieldProps) {
|
export default function PasswordField({
|
||||||
|
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>("");
|
||||||
|
|
||||||
|
|
@ -27,7 +33,7 @@ export default function PasswordField({onPasswordChanged, onKeyDown, className =
|
||||||
<input
|
<input
|
||||||
className="pr-10"
|
className="pr-10"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Passwort"
|
placeholder={placeholder}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => changePassword(e.target.value)}
|
onChange={(e) => changePassword(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
<ContentBackground>
|
<PageContentLayout>
|
||||||
{/* Header - remains in position when scrolling */}
|
{/* Header - remains in position when scrolling */}
|
||||||
<StickyHeader>
|
<StickyHeader>
|
||||||
<h1>{recipeWorkingCopy.title}</h1>
|
<h1>{recipeWorkingCopy.title}</h1>
|
||||||
|
|
@ -150,7 +150,7 @@ export default function RecipeDetailPage() {
|
||||||
/>
|
/>
|
||||||
</ButtonGroupLayout>
|
</ButtonGroupLayout>
|
||||||
</ContentBody>
|
</ContentBody>
|
||||||
</ContentBackground>
|
</PageContentLayout>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
frontend/src/components/users/ChangePasswordModal.tsx
Normal file
65
frontend/src/components/users/ChangePasswordModal.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// src/components/basics/ChangePasswordModal.tsx
|
||||||
|
import {useState} from "react";
|
||||||
|
import {changePassword} from "../../api/points/UserPoint";
|
||||||
|
import PasswordField from "../basics/PasswordField.tsx";
|
||||||
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
||||||
|
import Button from "../basics/Button.tsx";
|
||||||
|
import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
|
||||||
|
|
||||||
|
type ChangePasswordModalProps = {
|
||||||
|
userId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChangePasswordModal({userId, onClose}: ChangePasswordModalProps) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Passwörter stimmen nicht überein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await changePassword({userId, password});
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
setError("Fehler beim Ändern des Passworts");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
frontend/src/components/users/UserManagementPage.tsx
Normal file
269
frontend/src/components/users/UserManagementPage.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {createUser, fetchCurrentUser, updateUser} from "../../api/points/UserPoint";
|
||||||
|
import type {UserDto} from "../../api/dtos/UserDto.ts"; // @todo add model and mapper!
|
||||||
|
import ContentBackground from "../basics/ContentBackground";
|
||||||
|
import ContentBody from "../basics/ContentBody";
|
||||||
|
import StickyHeader from "../basics/StickyHeader";
|
||||||
|
import Button from "../basics/Button";
|
||||||
|
import {ButtonType} from "../basics/BasicButtonDefinitions";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import PageContainer from "../basics/PageContainer.tsx";
|
||||||
|
import {ChangePasswordModal} from "./ChangePasswordModal.tsx";
|
||||||
|
import PasswordField from "../basics/PasswordField.tsx";
|
||||||
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
||||||
|
import {Plus, X} from "lucide-react";
|
||||||
|
import ButtonLink from "../basics/ButtonLink.tsx";
|
||||||
|
import {getRecipeListUrl} from "../../routes.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserManagementPage
|
||||||
|
* -------------------
|
||||||
|
* Displays a two-column layout:
|
||||||
|
* - Left: list of all users
|
||||||
|
* - Right: edit form for selected or new user
|
||||||
|
*
|
||||||
|
* Allows:
|
||||||
|
* - Admins to manage all users (add/edit)
|
||||||
|
* - Regular users to edit their own profile
|
||||||
|
* - Password changes via modal
|
||||||
|
*/
|
||||||
|
export default function UserManagementPage() {
|
||||||
|
const [users, setUsers] = useState<UserDto[]>([]);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null);
|
||||||
|
const [currentUser, setCurrentUser] = useState<UserDto | null>(null);
|
||||||
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === "admin";
|
||||||
|
|
||||||
|
// Load current user and user list (if admin)
|
||||||
|
useEffect(() => {
|
||||||
|
loadData().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const me = await fetchCurrentUser();
|
||||||
|
setCurrentUser(me);
|
||||||
|
|
||||||
|
//if (me.role === "admin") {
|
||||||
|
// const allUsers = await fetchAllUsers();
|
||||||
|
// setUsers(allUsers);
|
||||||
|
//} else {
|
||||||
|
setUsers([me]);
|
||||||
|
setSelectedUser(me);
|
||||||
|
//}
|
||||||
|
};
|
||||||
|
/** Handles selecting a user from the list */
|
||||||
|
const handleSelectUser = (user: UserDto) => {
|
||||||
|
setSelectedUser({...user});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Handles saving user (create or update) */
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (!selectedUser.id) {
|
||||||
|
//@todo check passwords!
|
||||||
|
const created = await createUser({userData: selectedUser, password});
|
||||||
|
setUsers([...users, created]);
|
||||||
|
setSelectedUser(created);
|
||||||
|
} else {
|
||||||
|
const updated = await updateUser(selectedUser);
|
||||||
|
setUsers(users.map(u => (u.id === updated.id ? updated : u)));
|
||||||
|
setSelectedUser(updated);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Opens password modal */
|
||||||
|
const openPasswordModal = () => {
|
||||||
|
setPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setIsPasswordModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Handles creating a new user (admin only) */
|
||||||
|
const handleAddUser = () => {
|
||||||
|
setSelectedUser({
|
||||||
|
userName: "",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ContentBackground>
|
||||||
|
<StickyHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1>Benutzerverwaltung</h1>
|
||||||
|
<ButtonGroupLayout>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
icon={Plus}
|
||||||
|
onClick={handleAddUser}
|
||||||
|
text="Neuer Benutzer"
|
||||||
|
buttonType={ButtonType.PrimaryButton}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ButtonLink
|
||||||
|
icon={X}
|
||||||
|
to={getRecipeListUrl()}
|
||||||
|
/>
|
||||||
|
</ButtonGroupLayout>
|
||||||
|
</div>
|
||||||
|
</StickyHeader>
|
||||||
|
|
||||||
|
<ContentBody>
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{/* User List */}
|
||||||
|
<div className="md:w-1/3 border-r border-gray-300">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Benutzer</h2>
|
||||||
|
<ul>
|
||||||
|
{users.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.id ?? user.userName}
|
||||||
|
className={clsx(
|
||||||
|
"p-2 cursor-pointer rounded hover:bg-gray-200",
|
||||||
|
selectedUser?.id === user.id && "bg-gray-300 font-semibold"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
>
|
||||||
|
{user.firstName} {user.lastName} ({user.userName})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form */}
|
||||||
|
<div className="md:w-2/3">
|
||||||
|
{selectedUser ? (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
{selectedUser.id ? "Benutzer bearbeiten" : "Neuen Benutzer anlegen"}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3 max-w-md"
|
||||||
|
>
|
||||||
|
{/* @todo create component for laben and input field combination */}
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Benutzername"
|
||||||
|
value={selectedUser.userName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedUser({...selectedUser, userName: e.target.value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label>Vorname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Vorname"
|
||||||
|
value={selectedUser.firstName ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedUser({...selectedUser, firstName: e.target.value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label>Nachname</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nachname"
|
||||||
|
value={selectedUser.lastName ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedUser({...selectedUser, lastName: e.target.value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail"
|
||||||
|
value={selectedUser.email ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedUser({...selectedUser, email: e.target.value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (<label>Benutzergruppe</label>)}
|
||||||
|
{isAdmin && (
|
||||||
|
// @todo style
|
||||||
|
<select
|
||||||
|
className="input-field"
|
||||||
|
value={selectedUser.role ?? "user"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedUser({...selectedUser, role: e.target.value})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="user">Benutzer</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show password field only when creating new user */}
|
||||||
|
{!selectedUser.id && (<label>Passwort</label>)}
|
||||||
|
{!selectedUser.id && (
|
||||||
|
<PasswordField
|
||||||
|
onPasswordChanged={setPassword}
|
||||||
|
onKeyDown={() => {
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selectedUser.id && (<label>Passwort bestätigen</label>)}
|
||||||
|
{!selectedUser.id && (
|
||||||
|
<PasswordField
|
||||||
|
placeholder="Passwort bestätigen"
|
||||||
|
onPasswordChanged={setConfirmPassword}
|
||||||
|
onKeyDown={() => {
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Change password button for existing user */}
|
||||||
|
{selectedUser.id && (
|
||||||
|
<Button
|
||||||
|
text="Passwort ändern"
|
||||||
|
onClick={openPasswordModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ButtonGroupLayout>
|
||||||
|
<Button
|
||||||
|
text={isSaving ? "Speichern..." : "Speichern"}
|
||||||
|
onClick={handleSave}
|
||||||
|
buttonType={ButtonType.PrimaryButton}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text={"Abbrechen"}
|
||||||
|
onClick={loadData}
|
||||||
|
/>
|
||||||
|
</ButtonGroupLayout>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600">Bitte einen Benutzer auswählen.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContentBody>
|
||||||
|
|
||||||
|
{/* Password Change Modal */}
|
||||||
|
{isPasswordModalOpen && (
|
||||||
|
<ChangePasswordModal
|
||||||
|
userId={selectedUser?.id}
|
||||||
|
onClose={() => setIsPasswordModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContentBackground>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,59 @@
|
||||||
* 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 { return getRootUrl()}
|
export function getRootUrlDefinition(): string {
|
||||||
export function getRecipeDetailsUrlDefinition() : string {return getRecipeDetailUrl(":id")}
|
return getRootUrl()
|
||||||
export function getRecipeEditUrlDefinition() : string {return getRecipeEditUrl(":id")}
|
}
|
||||||
export function getRecipeAddUrlDefinition() : string {return getRecipeAddUrl()}
|
|
||||||
export function getRecipeListUrlDefinition() : string {return getRecipeListUrl()}
|
export function getRecipeDetailsUrlDefinition(): string {
|
||||||
export function getLoginUrlDefinition() : string {return getLoginUrl()}
|
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
|
// URLs including id
|
||||||
export function getRootUrl () : string { return "/"}
|
export function getRootUrl(): string {
|
||||||
export function getRecipeListUrl() : string {return "/recipe/list"}
|
return "/"
|
||||||
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 getRecipeListUrl(): string {
|
||||||
export function getLoginUrl() : string {return "/login"}
|
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"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue