add basic user management

This commit is contained in:
araemer 2025-11-02 09:09:34 +01:00
parent 09150ba3bb
commit 9e7ad622f9
12 changed files with 673 additions and 35 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 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>
) )

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 ChangeUserPasswordRequestDto {
userId?: string;
password?: string;
}

View file

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

View 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);
}

View 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>
);
}

View 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>
);
}

View file

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

View file

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

View 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>
);
}

View 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>
);
}

View file

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