// frontend\src\pages\settings\konto\Konto.tsx import { useEffect, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode, } from 'react' import Button from '../../../components/Button' import Container from '../../../components/Container' import type { User } from '../../../components/types' import { ComputerDesktopIcon, ShieldCheckIcon, } from '@heroicons/react/20/solid' import { useNotifications } from '../../../components/Notifications' import Avatar from '../../../components/Avatar' import Combobox, { type ComboboxItem } from '../../../components/Combobox' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' type KontoProps = { user?: User onUserUpdated?: (user: User) => void } type UserSession = { id: string browser: string operatingSystem: string deviceType: string userAgent: string ipAddress: string createdAt: string lastSeenAt: string current: boolean } const inputClassName = 'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500' function Section({ title, description, children, }: { title: string description: string children: ReactNode }) { return (

{title}

{description}

{children}
) } async function readApiError(response: Response, fallback: string) { const data = await response.json().catch(() => null) return data?.error ?? fallback } export default function Konto({ user, onUserUpdated, }: KontoProps) { const { showToast, showErrorToast } = useNotifications() const [currentUser, setCurrentUser] = useState(user) const [avatarPreview, setAvatarPreview] = useState(user?.avatar ?? '') const avatarInputRef = useRef(null) const [sessions, setSessions] = useState([]) const [isLoadingSessions, setIsLoadingSessions] = useState(false) const [revokingSessionId, setRevokingSessionId] = useState(null) const [isSavingKonto, setIsSavingKonto] = useState(false) const [isSavingPassword, setIsSavingPassword] = useState(false) const [isLoggingOutSessions, setIsLoggingOutSessions] = useState(false) const [isDeletingKonto, setIsDeletingKonto] = useState(false) useEffect(() => { setCurrentUser(user) setAvatarPreview(user?.avatar ?? '') }, [user]) useEffect(() => { void loadSessions() }, []) async function loadSessions() { setIsLoadingSessions(true) try { const response = await fetch(`${API_URL}/settings/sessions`, { credentials: 'include', }) if (!response.ok) { throw new Error( await readApiError(response, 'Sitzungen konnten nicht geladen werden'), ) } const data = await response.json() setSessions(data.sessions ?? []) } catch (error) { showToast({ title: 'Sitzungen konnten nicht geladen werden', message: error instanceof Error ? error.message : 'Sitzungen konnten nicht geladen werden', variant: 'error', }) } finally { setIsLoadingSessions(false) } } async function handleRevokeSession(sessionId: string) { setRevokingSessionId(sessionId) try { const response = await fetch(`${API_URL}/settings/sessions/${sessionId}`, { method: 'DELETE', credentials: 'include', }) if (!response.ok) { throw new Error( await readApiError(response, 'Sitzung konnte nicht abgemeldet werden'), ) } await loadSessions() showToast({ title: 'Sitzung abgemeldet', message: 'Die ausgewählte Sitzung wurde abgemeldet.', variant: 'success', }) } catch (error) { showToast({ title: 'Sitzung konnte nicht abgemeldet werden', message: error instanceof Error ? error.message : 'Sitzung konnte nicht abgemeldet werden', variant: 'error', autoClose: false, }) } finally { setRevokingSessionId(null) } } function handleAvatarFileChange(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) { return } if (!file.type.startsWith('image/')) { showToast({ title: 'Ungültige Datei', message: 'Bitte wähle eine gültige Bilddatei aus.', variant: 'error', }) event.target.value = '' return } if (file.size > 2 * 1024 * 1024) { showToast({ title: 'Bild zu groß', message: 'Das Profilbild darf maximal 2 MB groß sein.', variant: 'error', }) event.target.value = '' return } const reader = new FileReader() reader.onload = () => { if (typeof reader.result === 'string') { setAvatarPreview(reader.result) } } reader.readAsDataURL(file) event.target.value = '' } function handleRemoveAvatar() { setAvatarPreview('') } async function handleKontoSubmit(event: FormEvent) { event.preventDefault() const form = event.currentTarget const formData = new FormData(form) setIsSavingKonto(true) try { const response = await fetch(`${API_URL}/settings/account`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ displayName: String(formData.get('displayName') ?? ''), username: String(formData.get('username') ?? ''), email: String(formData.get('email') ?? ''), avatar: String(formData.get('avatar') ?? ''), }), }) if (!response.ok) { throw new Error( await readApiError(response, 'Profil konnte nicht gespeichert werden'), ) } const data = await response.json().catch(() => null) const nextUser = data?.user as User | undefined if (nextUser) { setCurrentUser(nextUser) setAvatarPreview(nextUser.avatar ?? '') onUserUpdated?.(nextUser) } showToast({ title: 'Profil gespeichert', message: 'Deine Profildaten wurden gespeichert.', variant: 'success', }) } catch (error) { showErrorToast({ title: 'Profil konnte nicht gespeichert werden', error, fallback: 'Profil konnte nicht gespeichert werden', }) } finally { setIsSavingKonto(false) } } async function handlePasswordSubmit(event: FormEvent) { event.preventDefault() const form = event.currentTarget const formData = new FormData(form) setIsSavingPassword(true) try { const response = await fetch(`${API_URL}/settings/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ currentPassword: String(formData.get('currentPassword') ?? ''), newPassword: String(formData.get('newPassword') ?? ''), confirmPassword: String(formData.get('confirmPassword') ?? ''), }), }) if (!response.ok) { throw new Error( await readApiError(response, 'Passwort konnte nicht gespeichert werden'), ) } form.reset() showToast({ title: 'Passwort gespeichert', message: 'Dein Passwort wurde erfolgreich geändert.', variant: 'success', }) } catch (error) { showToast({ title: 'Passwort konnte nicht gespeichert werden', message: error instanceof Error ? error.message : 'Passwort konnte nicht gespeichert werden', variant: 'error', autoClose: false, }) } finally { setIsSavingPassword(false) } } async function handleLogoutSessionsSubmit(event: FormEvent) { event.preventDefault() const form = event.currentTarget const formData = new FormData(form) setIsLoggingOutSessions(true) try { const response = await fetch(`${API_URL}/settings/logout-other-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ password: String(formData.get('password') ?? ''), }), }) if (!response.ok) { throw new Error( await readApiError( response, 'Andere Sitzungen konnten nicht abgemeldet werden', ), ) } form.reset() showToast({ title: 'Andere Sitzungen abgemeldet', message: 'Alle anderen aktiven Sitzungen wurden abgemeldet.', variant: 'success', }) await loadSessions() } catch (error) { showToast({ title: 'Andere Sitzungen konnten nicht abgemeldet werden', message: error instanceof Error ? error.message : 'Andere Sitzungen konnten nicht abgemeldet werden', variant: 'error', autoClose: false, }) } finally { setIsLoggingOutSessions(false) } } async function handleDeleteKontoSubmit(event: FormEvent) { event.preventDefault() const form = event.currentTarget const formData = new FormData(form) const confirmed = window.confirm( 'Konto wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.', ) if (!confirmed) { return } setIsDeletingKonto(true) try { const response = await fetch(`${API_URL}/settings/account`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ password: String(formData.get('password') ?? ''), }), }) if (!response.ok) { throw new Error( await readApiError(response, 'Konto konnte nicht gelöscht werden'), ) } window.location.href = '/login' } catch (error) { showToast({ title: 'Konto konnte nicht gelöscht werden', message: error instanceof Error ? error.message : 'Konto konnte nicht gelöscht werden', variant: 'error', autoClose: false, }) } finally { setIsDeletingKonto(false) } } return ( <>
{avatarPreview && ( )}

PNG, JPG, WEBP oder GIF bis maximal 2 MB.

undefined} placeholder="Keine Einheit zugeordnet" disabled />

Die Einheit kann nur in der Benutzeradministration geändert werden.

{isLoadingSessions && (
Sitzungen werden geladen...
)} {!isLoadingSessions && sessions.length > 0 && (
{sessions.map((session) => (

{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}

{session.browser} auf {session.operatingSystem}

{session.deviceType} {session.ipAddress ? ` · ${session.ipAddress}` : ''}

Zuletzt aktiv:{' '} {new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short', }).format(new Date(session.lastSeenAt))}

{session.current ? ( ) : ( )}
))}
)} {!isLoadingSessions && sessions.length === 0 && (
Keine aktiven Sitzungen gefunden.
)}

Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.

) }