Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
288
components/admin/UserManagement.tsx
Normal file
288
components/admin/UserManagement.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Benutzerverwaltung (Client): Liste aller Benutzer mit Edit-Modal.
|
||||
* Im Modal laesst sich die Rolle aendern (PATCH /api/users/[id]);
|
||||
* Fehler werden inline angezeigt. Der eigene Account ist geschuetzt
|
||||
* (Server lehnt ab).
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, ErrorMessage, Badge } from '@/components/ui/ui';
|
||||
import { ModalShell } from '@/components/ui/ModalShell';
|
||||
|
||||
interface ManagedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string | null;
|
||||
role: 'ADMIN' | 'USER';
|
||||
createdAt: string;
|
||||
memberships: { calendar: { id: string; title: string; isActive: boolean } }[];
|
||||
ownedCalendars: { id: string; title: string; isActive: boolean }[];
|
||||
}
|
||||
|
||||
export function UserManagement({ currentUserId }: { currentUserId: string }) {
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
/** Aktuell im Edit-Modal geoeffneter Benutzer (null = geschlossen). */
|
||||
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/users', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
throw new Error(data?.error ?? 'Benutzer konnten nicht geladen werden.');
|
||||
}
|
||||
const data = (await response.json()) as { users: ManagedUser[] };
|
||||
setUsers(data.users);
|
||||
} catch (loadError) {
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: 'Benutzer konnten nicht geladen werden.',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
}, [loadUsers]);
|
||||
|
||||
/**
|
||||
* Aktualisiert die Rolle eines Benutzers und schliesst bei Erfolg
|
||||
* das Modal. Fehler werden im Modal (falls offen) bzw. inline
|
||||
* angezeigt.
|
||||
*/
|
||||
async function changeRole(userId: string, nextRole: 'ADMIN' | 'USER') {
|
||||
setPendingId(userId);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: nextRole }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
throw new Error(data?.error ?? 'Rolle konnte nicht geaendert werden.');
|
||||
}
|
||||
const data = (await response.json()) as { user: ManagedUser };
|
||||
setUsers((previous) =>
|
||||
previous.map((user) =>
|
||||
user.id === data.user.id ? { ...user, role: data.user.role } : user,
|
||||
),
|
||||
);
|
||||
setEditingUser((previous) =>
|
||||
previous && previous.id === data.user.id
|
||||
? { ...previous, role: data.user.role }
|
||||
: previous,
|
||||
);
|
||||
} catch (changeError) {
|
||||
setError(
|
||||
changeError instanceof Error
|
||||
? changeError.message
|
||||
: 'Rolle konnte nicht geaendert werden.',
|
||||
);
|
||||
} finally {
|
||||
setPendingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Benutzer werden geladen...
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{users.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Noch keine Benutzer registriert.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{users.map((user) => {
|
||||
const isSelf = user.id === currentUserId;
|
||||
return (
|
||||
<li key={user.id}>
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-slate-900 dark:text-slate-100">
|
||||
{user.username ?? user.email}
|
||||
{user.username && (
|
||||
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
|
||||
{user.email}
|
||||
</span>
|
||||
)}
|
||||
{isSelf && (
|
||||
<span className="ml-2">
|
||||
<Badge variant="accent">Du</Badge>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
Registriert am{' '}
|
||||
{new Date(user.createdAt).toLocaleDateString('de-DE')}
|
||||
{user.ownedCalendars.length > 0 &&
|
||||
` · ${user.ownedCalendars.length} Kalender`}
|
||||
{user.memberships.length > 0 &&
|
||||
` · Mitglied in ${user.memberships.length} Kalender(n)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setEditingUser(user);
|
||||
}}
|
||||
aria-label={`Benutzer ${user.email} bearbeiten`}
|
||||
title="Bearbeiten"
|
||||
className="rounded-md p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
>
|
||||
{/* Stift-Icon (2D, Outline-Stil). */}
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
isSelf={editingUser.id === currentUserId}
|
||||
isPending={pendingId === editingUser.id}
|
||||
error={error}
|
||||
onClose={() => {
|
||||
setEditingUser(null);
|
||||
setError(null);
|
||||
}}
|
||||
onSave={(nextRole) => changeRole(editingUser.id, nextRole)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal zum Bearbeiten eines Benutzers (derzeit: Rolle).
|
||||
* Schliessen via Abbrechen, X-Button, ESC oder Klick auf den Hintergrund.
|
||||
*/
|
||||
function EditUserModal({
|
||||
user,
|
||||
isSelf,
|
||||
isPending,
|
||||
error,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
user: ManagedUser;
|
||||
isSelf: boolean;
|
||||
isPending: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onSave: (nextRole: 'ADMIN' | 'USER') => void;
|
||||
}) {
|
||||
const [selectedRole, setSelectedRole] = useState<'ADMIN' | 'USER'>(user.role);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="Benutzer bearbeiten"
|
||||
ariaLabel={`Benutzer ${user.email} bearbeiten`}
|
||||
isPending={isPending}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{user.username ?? user.email}
|
||||
{user.username && (
|
||||
<span className="ml-2 text-xs font-normal text-slate-500 dark:text-slate-400">
|
||||
{user.email}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
|
||||
Registriert am{' '}
|
||||
{new Date(user.createdAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="edit-user-role"
|
||||
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Rolle
|
||||
</label>
|
||||
<select
|
||||
id="edit-user-role"
|
||||
value={selectedRole}
|
||||
disabled={isSelf || isPending}
|
||||
onChange={(event) =>
|
||||
setSelectedRole(event.target.value as 'ADMIN' | 'USER')
|
||||
}
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
{isSelf && (
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
Die eigene Rolle kann nicht geaendert werden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <ErrorMessage message={error} />}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" disabled={isPending} onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSelf || isPending || selectedRole === user.role}
|
||||
onClick={() => onSave(selectedRole)}
|
||||
>
|
||||
{isPending ? 'Speichern …' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user