2026-06-13 10:15:57 +02:00

831 lines
25 KiB
TypeScript

// 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 (
<Container variant="constrained" className="py-16">
<div className="grid grid-cols-1 gap-x-8 gap-y-10 md:grid-cols-3">
<div>
<h2 className="text-base/7 font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
<div className="md:col-span-2">
{children}
</div>
</div>
</Container>
)
}
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 | undefined>(user)
const [avatarPreview, setAvatarPreview] = useState(user?.avatar ?? '')
const avatarInputRef = useRef<HTMLInputElement | null>(null)
const [sessions, setSessions] = useState<UserSession[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(false)
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(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<HTMLInputElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<>
<div className="divide-y divide-gray-200 dark:divide-white/10">
<Section
title="Persönliche Informationen"
description="Verwalte deine Profildaten und Kontoinformationen."
>
<form onSubmit={handleKontoSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full flex items-center gap-x-8">
<Avatar
src={avatarPreview}
name={currentUser?.displayName || currentUser?.username || currentUser?.email}
size={16}
shape="rounded"
/>
<div className="min-w-0 flex-1">
<input
type="hidden"
name="avatar"
value={avatarPreview}
/>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleAvatarFileChange}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => avatarInputRef.current?.click()}
>
Bild hochladen
</Button>
{avatarPreview && (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
onClick={handleRemoveAvatar}
>
Entfernen
</Button>
)}
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
PNG, JPG, WEBP oder GIF bis maximal 2 MB.
</p>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="displayName"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Anzeigename
</label>
<div className="mt-2">
<input
id="displayName"
name="displayName"
type="text"
defaultValue={currentUser?.displayName ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label
htmlFor="username"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Benutzername
</label>
<div className="mt-2">
<input
id="username"
name="username"
type="text"
required
defaultValue={currentUser?.username ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="email"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
E-Mail-Adresse
</label>
<div className="mt-2">
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={currentUser?.email ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<Combobox
label="Einheit"
items={
currentUser?.unit
? [{ id: currentUser.unit, name: currentUser.unit }]
: []
}
value={
currentUser?.unit
? ({
id: currentUser.unit,
name: currentUser.unit,
} satisfies ComboboxItem)
: null
}
onChange={() => undefined}
placeholder="Keine Einheit zugeordnet"
disabled
/>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Die Einheit kann nur in der Benutzeradministration geändert werden.
</p>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingKonto}
>
Speichern
</Button>
</div>
</form>
</Section>
<Section
title="Passwort ändern"
description="Aktualisiere das Passwort deines Benutzerkontos."
>
<form onSubmit={handlePasswordSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full">
<label
htmlFor="current-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Aktuelles Passwort
</label>
<div className="mt-2">
<input
id="current-password"
name="currentPassword"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="new-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort
</label>
<div className="mt-2">
<input
id="new-password"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
<div className="col-span-full">
<label
htmlFor="confirm-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Neues Passwort bestätigen
</label>
<div className="mt-2">
<input
id="confirm-password"
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isSavingPassword}
>
Passwort speichern
</Button>
</div>
</form>
</Section>
<Section
title="Sitzungen"
description="Sieh dir deine aktiven Sitzungen an und melde andere Browser ab."
>
<div className="sm:max-w-2xl">
{isLoadingSessions && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Sitzungen werden geladen...
</div>
)}
{!isLoadingSessions && sessions.length > 0 && (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5"
>
<div className="flex items-start gap-x-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 ring-1 ring-indigo-600/10 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<ComputerDesktopIcon
aria-hidden="true"
className="size-6"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-x-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{session.current ? 'Aktuelle Sitzung' : 'Weitere Sitzung'}
</h3>
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
{session.browser} auf {session.operatingSystem}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{session.deviceType}
{session.ipAddress ? ` · ${session.ipAddress}` : ''}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Zuletzt aktiv:{' '}
{new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(session.lastSeenAt))}
</p>
</div>
{session.current ? (
<span className="inline-flex shrink-0 items-center gap-x-1.5 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30">
<ShieldCheckIcon
aria-hidden="true"
className="size-3.5"
/>
Aktiv
</span>
) : (
<Button
type="button"
variant="secondary"
color="red"
size="sm"
isLoading={revokingSessionId === session.id}
onClick={() => void handleRevokeSession(session.id)}
>
Abmelden
</Button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{!isLoadingSessions && sessions.length === 0 && (
<div className="rounded-md border border-dashed border-gray-300 p-4 text-sm text-gray-500 dark:border-white/10 dark:text-gray-400">
Keine aktiven Sitzungen gefunden.
</div>
)}
<form
onSubmit={handleLogoutSessionsSubmit}
className="mt-8 border-t border-gray-200 pt-6 dark:border-white/10"
>
<label
htmlFor="logout-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="logout-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
<p className="mt-2 text-xs/5 text-gray-500 dark:text-gray-400">
Dadurch werden alle anderen aktiven Sitzungen abgemeldet. Diese Sitzung bleibt aktiv.
</p>
<div className="mt-6 flex">
<Button
type="submit"
color="indigo"
size="md"
isLoading={isLoggingOutSessions}
>
Andere Sitzungen abmelden
</Button>
</div>
</form>
</div>
</Section>
<Section
title="Konto löschen"
description="Das Löschen deines Kontos kann nicht rückgängig gemacht werden."
>
<form onSubmit={handleDeleteKontoSubmit}>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:max-w-xl sm:grid-cols-6">
<div className="col-span-full">
<label
htmlFor="delete-konto-password"
className="block text-sm/6 font-medium text-gray-900 dark:text-white"
>
Dein Passwort
</label>
<div className="mt-2">
<input
id="delete-konto-password"
name="password"
type="password"
autoComplete="current-password"
required
className={inputClassName}
/>
</div>
</div>
</div>
<div className="mt-8 flex">
<Button
type="submit"
color="red"
size="md"
isLoading={isDeletingKonto}
>
Konto löschen
</Button>
</div>
</form>
</Section>
</div>
</>
)
}