feat: Add password confirmation and profile editing to user management
Frontend: - Add password confirmation field when creating sub-users - Add password mismatch validation - Add 'Meine Stammdaten' form for primary users to edit their profile - Extend session context with updateUser() function - Disable autocomplete for sensitive fields
This commit is contained in:
@@ -14,12 +14,14 @@ interface SessionContextValue {
|
|||||||
user: UserOption | null;
|
user: UserOption | null;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
setSession: (session: SessionResponse | null) => void;
|
setSession: (session: SessionResponse | null) => void;
|
||||||
|
updateUser: (user: UserOption) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionContext = createContext<SessionContextValue>({
|
const SessionContext = createContext<SessionContextValue>({
|
||||||
user: null,
|
user: null,
|
||||||
ready: false,
|
ready: false,
|
||||||
setSession: () => undefined,
|
setSession: () => undefined,
|
||||||
|
updateUser: () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadStoredUser(): UserOption | null {
|
function loadStoredUser(): UserOption | null {
|
||||||
@@ -91,11 +93,16 @@ export function SessionProvider({ children }: PropsWithChildren) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUser(updatedUser: UserOption) {
|
||||||
|
setUserState(updatedUser);
|
||||||
|
}
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user,
|
user,
|
||||||
ready,
|
ready,
|
||||||
setSession,
|
setSession,
|
||||||
|
updateUser,
|
||||||
}),
|
}),
|
||||||
[ready, user],
|
[ready, user],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface SubUserRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserManagementPage() {
|
export default function UserManagementPage() {
|
||||||
const { user } = useSession();
|
const { user, updateUser } = useSession();
|
||||||
const [users, setUsers] = useState<SubUserRow[]>([]);
|
const [users, setUsers] = useState<SubUserRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
@@ -25,8 +25,39 @@ export default function UserManagementPage() {
|
|||||||
const [newUserName, setNewUserName] = useState("");
|
const [newUserName, setNewUserName] = useState("");
|
||||||
const [newUserEmail, setNewUserEmail] = useState("");
|
const [newUserEmail, setNewUserEmail] = useState("");
|
||||||
const [newUserPassword, setNewUserPassword] = useState("");
|
const [newUserPassword, setNewUserPassword] = useState("");
|
||||||
|
const [newUserPasswordConfirm, setNewUserPasswordConfirm] = useState("");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Form state for editing own profile
|
||||||
|
const [showProfileForm, setShowProfileForm] = useState(false);
|
||||||
|
const [profileData, setProfileData] = useState({
|
||||||
|
displayName: "",
|
||||||
|
companyName: "",
|
||||||
|
street: "",
|
||||||
|
houseNumber: "",
|
||||||
|
postalCode: "",
|
||||||
|
city: "",
|
||||||
|
email: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
});
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
|
||||||
|
// Initialize profile data from session user
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setProfileData({
|
||||||
|
displayName: user.displayName || "",
|
||||||
|
companyName: user.companyName || "",
|
||||||
|
street: user.street || "",
|
||||||
|
houseNumber: user.houseNumber || "",
|
||||||
|
postalCode: user.postalCode || "",
|
||||||
|
city: user.city || "",
|
||||||
|
email: user.email || "",
|
||||||
|
phoneNumber: user.phoneNumber || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +129,10 @@ export default function UserManagementPage() {
|
|||||||
setMessage("Bitte alle Felder ausfüllen.");
|
setMessage("Bitte alle Felder ausfüllen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (newUserPassword !== newUserPasswordConfirm) {
|
||||||
|
setMessage("Die Passwörter stimmen nicht überein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +159,7 @@ export default function UserManagementPage() {
|
|||||||
setNewUserName("");
|
setNewUserName("");
|
||||||
setNewUserEmail("");
|
setNewUserEmail("");
|
||||||
setNewUserPassword("");
|
setNewUserPassword("");
|
||||||
|
setNewUserPasswordConfirm("");
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setMessage(`Benutzer "${newUserName}" wurde erstellt.`);
|
setMessage(`Benutzer "${newUserName}" wurde erstellt.`);
|
||||||
setTimeout(() => setMessage(null), 3000);
|
setTimeout(() => setMessage(null), 3000);
|
||||||
@@ -134,6 +170,52 @@ export default function UserManagementPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveProfile(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profileData.displayName.trim()) {
|
||||||
|
setMessage("Name ist erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
const response = await apiPost<UserRow>("/portal/users", {
|
||||||
|
id: user?.id,
|
||||||
|
displayName: profileData.displayName.trim(),
|
||||||
|
companyName: profileData.companyName.trim() || null,
|
||||||
|
street: profileData.street.trim() || null,
|
||||||
|
houseNumber: profileData.houseNumber.trim() || null,
|
||||||
|
postalCode: profileData.postalCode.trim() || null,
|
||||||
|
city: profileData.city.trim() || null,
|
||||||
|
email: profileData.email.trim() || null,
|
||||||
|
phoneNumber: profileData.phoneNumber.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update session user
|
||||||
|
if (updateUser && user) {
|
||||||
|
updateUser({
|
||||||
|
...user,
|
||||||
|
displayName: response.displayName,
|
||||||
|
companyName: response.companyName,
|
||||||
|
street: response.street,
|
||||||
|
houseNumber: response.houseNumber,
|
||||||
|
postalCode: response.postalCode,
|
||||||
|
city: response.city,
|
||||||
|
email: response.email,
|
||||||
|
phoneNumber: response.phoneNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowProfileForm(false);
|
||||||
|
setMessage("Ihre Stammdaten wurden aktualisiert.");
|
||||||
|
setTimeout(() => setMessage(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage((error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
return new Intl.DateTimeFormat("de-DE", {
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
@@ -174,7 +256,8 @@ export default function UserManagementPage() {
|
|||||||
className={
|
className={
|
||||||
message.includes("freigegeben") ||
|
message.includes("freigegeben") ||
|
||||||
message.includes("gesperrt") ||
|
message.includes("gesperrt") ||
|
||||||
message.includes("erstellt")
|
message.includes("erstellt") ||
|
||||||
|
message.includes("aktualisiert")
|
||||||
? "alert alert--success"
|
? "alert alert--success"
|
||||||
: "alert alert--error"
|
: "alert alert--error"
|
||||||
}
|
}
|
||||||
@@ -183,6 +266,127 @@ export default function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Own Profile Form (nur für Hauptbenutzer) */}
|
||||||
|
{isPrimaryUser && !isAdmin && (
|
||||||
|
<section className="section-card">
|
||||||
|
{!showProfileForm ? (
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Meine Stammdaten</p>
|
||||||
|
<h3>{user?.displayName}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="accent-button"
|
||||||
|
onClick={() => setShowProfileForm(true)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="section-card__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Meine Stammdaten</p>
|
||||||
|
<h3>Stammdaten bearbeiten</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => setShowProfileForm(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSaveProfile} className="field-grid field-grid--2col">
|
||||||
|
<label className="field">
|
||||||
|
<span>Name *</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.displayName}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, displayName: e.target.value })}
|
||||||
|
placeholder="Ihr Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Firma</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.companyName}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, companyName: e.target.value })}
|
||||||
|
placeholder="Firmenname"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Straße</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.street}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, street: e.target.value })}
|
||||||
|
placeholder="Straße"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Hausnummer</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.houseNumber}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, houseNumber: e.target.value })}
|
||||||
|
placeholder="Hausnummer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>PLZ</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.postalCode}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, postalCode: e.target.value })}
|
||||||
|
placeholder="Postleitzahl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Ort</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={profileData.city}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, city: e.target.value })}
|
||||||
|
placeholder="Ort"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>E-Mail</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={profileData.email}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
|
||||||
|
placeholder="email@beispiel.de"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Telefon</span>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={profileData.phoneNumber}
|
||||||
|
onChange={(e) => setProfileData({ ...profileData, phoneNumber: e.target.value })}
|
||||||
|
placeholder="Telefonnummer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="field" style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="accent-button"
|
||||||
|
disabled={savingProfile}
|
||||||
|
>
|
||||||
|
{savingProfile ? "Wird gespeichert..." : "Stammdaten speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Sub-user Form (nur für Hauptnutzer) */}
|
{/* Create Sub-user Form (nur für Hauptnutzer) */}
|
||||||
{isPrimaryUser && !isAdmin && (
|
{isPrimaryUser && !isAdmin && (
|
||||||
<section className="section-card">
|
<section className="section-card">
|
||||||
@@ -217,32 +421,46 @@ export default function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleCreateUser} className="field-grid field-grid--2col">
|
<form onSubmit={handleCreateUser} className="field-grid field-grid--2col">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Name</span>
|
<span>Name *</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newUserName}
|
value={newUserName}
|
||||||
onChange={(e) => setNewUserName(e.target.value)}
|
onChange={(e) => setNewUserName(e.target.value)}
|
||||||
placeholder="Name des Benutzers"
|
placeholder="Name des Benutzers"
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>E-Mail</span>
|
<span>E-Mail *</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={newUserEmail}
|
value={newUserEmail}
|
||||||
onChange={(e) => setNewUserEmail(e.target.value)}
|
onChange={(e) => setNewUserEmail(e.target.value)}
|
||||||
placeholder="email@beispiel.de"
|
placeholder="email@beispiel.de"
|
||||||
|
autoComplete="off"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Passwort</span>
|
<span>Passwort *</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newUserPassword}
|
value={newUserPassword}
|
||||||
onChange={(e) => setNewUserPassword(e.target.value)}
|
onChange={(e) => setNewUserPassword(e.target.value)}
|
||||||
placeholder="Passwort"
|
placeholder="Passwort"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Passwort wiederholen *</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newUserPasswordConfirm}
|
||||||
|
onChange={(e) => setNewUserPasswordConfirm(e.target.value)}
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user