13b70dfbe8
WINDOWS #36, Aufgabe 3/3: canManageRow spiegelt den Zielrollen-Riegel aus apps/api/src/user/user.controller.ts (update/remove, WINDOWS #29) rein ergonomisch — die Serverpruefung bleibt unveraendert und ist die einzige wirksame Grenze. Bearbeiten und Loeschen entfallen jetzt in der Zeile eines SUPER_ADMIN, wenn die angemeldete Person selbst keiner ist; Details bleibt in jeder Zeile. Die Sperre gegen Selbstloeschung bleibt unveraendert. Gesamtbestand apps/web: 66 Dateien / 459 Tests gruen, type-check Exit 0, lint 5/5 erfolgreich, apps/api unangetastet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { useTranslations } from 'next-intl';
|
||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||
import { UserAccessModal } from './components/UserAccessModal';
|
||
|
||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||
|
||
interface User {
|
||
id: string;
|
||
username: string;
|
||
// WINDOWS #15: ein Konto ohne Adresse (kollidierte AD-Adresse) ist
|
||
// moeglich, seit User.email optional ist. Die Handanlage (UserFormData)
|
||
// verlangt weiterhin eine Adresse.
|
||
email: string | null;
|
||
displayName: string | null;
|
||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||
isActive: boolean;
|
||
tenantId: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface UserFormData {
|
||
username: string;
|
||
email: string;
|
||
password: string;
|
||
displayName: string;
|
||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||
}
|
||
|
||
/**
|
||
* Extracts the backend's error message from a non-2xx JSON error body
|
||
* (Nest's default exception filter shape: `{ statusCode, message, error }`).
|
||
* Bewusst lokal kopiert statt aus `apps/web/src/lib/tender-radar-api.ts`
|
||
* importiert (jene Datei gehoert zum Modul Ausschreibungs-Radar) — nur das
|
||
* Feld `message` wird gelesen, nie der ganze Rumpf (T-A1D-01).
|
||
*/
|
||
async function readApiMessage(res: Response): Promise<string | null> {
|
||
try {
|
||
const body = (await res.json()) as { message?: unknown };
|
||
if (typeof body.message === 'string' && body.message) return body.message;
|
||
if (Array.isArray(body.message) && body.message.length) {
|
||
return body.message.join(', ');
|
||
}
|
||
} catch {
|
||
/* Rumpf war kein JSON — Ersatzmeldung greift beim Aufrufer */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Admin users page -- User CRUD management (D-12).
|
||
* ADMIN and SUPER_ADMIN can access. ADMIN sees only own-tenant users.
|
||
*/
|
||
export default function AdminUsersPage() {
|
||
const t = useTranslations('admin.users');
|
||
const tCommon = useTranslations('common');
|
||
const tHeader = useTranslations('header');
|
||
const currentUser = useAuthStore((s) => s.user);
|
||
|
||
const [users, setUsers] = useState<User[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||
const [formError, setFormError] = useState<string | null>(null);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
const [detailsUser, setDetailsUser] = useState<User | null>(null);
|
||
const [formData, setFormData] = useState<UserFormData>({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
displayName: '',
|
||
role: 'USER',
|
||
});
|
||
|
||
// Access check: only ADMIN and SUPER_ADMIN
|
||
const hasAccess =
|
||
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||
|
||
const fetchUsers = useCallback(async () => {
|
||
setLoadError(null);
|
||
try {
|
||
const res = await fetch(`${API_URL}/users`, {
|
||
credentials: 'include',
|
||
});
|
||
if (res.ok) {
|
||
setUsers(await res.json());
|
||
} else {
|
||
setLoadError(t('errors.loadFailed'));
|
||
}
|
||
} catch {
|
||
setLoadError(t('errors.loadFailed'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [t]);
|
||
|
||
useEffect(() => {
|
||
if (hasAccess) {
|
||
fetchUsers();
|
||
} else {
|
||
setLoading(false);
|
||
}
|
||
}, [hasAccess, fetchUsers]);
|
||
|
||
const openCreate = () => {
|
||
setEditingUser(null);
|
||
setFormData({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
displayName: '',
|
||
role: 'USER',
|
||
});
|
||
setFormError(null);
|
||
setShowForm(true);
|
||
};
|
||
|
||
const openEdit = (user: User) => {
|
||
setEditingUser(user);
|
||
setFormData({
|
||
username: user.username,
|
||
email: user.email ?? '',
|
||
password: '',
|
||
displayName: user.displayName ?? '',
|
||
role: user.role,
|
||
});
|
||
setFormError(null);
|
||
setShowForm(true);
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setFormError(null);
|
||
|
||
const url = editingUser
|
||
? `${API_URL}/users/${editingUser.id}`
|
||
: `${API_URL}/users`;
|
||
const method = editingUser ? 'PATCH' : 'POST';
|
||
|
||
const body: Record<string, unknown> = {
|
||
username: formData.username,
|
||
email: formData.email,
|
||
displayName: formData.displayName || undefined,
|
||
role: formData.role,
|
||
};
|
||
|
||
// Only include password if provided
|
||
if (formData.password) {
|
||
body.password = formData.password;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
if (res.ok) {
|
||
setShowForm(false);
|
||
fetchUsers();
|
||
} else {
|
||
const detail = await readApiMessage(res);
|
||
setFormError(
|
||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||
);
|
||
}
|
||
} catch {
|
||
setFormError(t('errors.network'));
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
setDeleteError(null);
|
||
try {
|
||
const res = await fetch(`${API_URL}/users/${id}`, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
});
|
||
if (res.ok) {
|
||
setDeleteConfirm(null);
|
||
fetchUsers();
|
||
} else {
|
||
const detail = await readApiMessage(res);
|
||
setDeleteError(
|
||
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
|
||
);
|
||
}
|
||
} catch {
|
||
setDeleteError(t('errors.network'));
|
||
}
|
||
};
|
||
|
||
if (!hasAccess) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Spiegelt den Zielrollen-Riegel aus `apps/api/src/user/user.controller.ts`
|
||
// (update/remove, WINDOWS #29): eine Zeile ist gesperrt, wenn ihre Person
|
||
// SUPER_ADMIN ist und die angemeldete Person es nicht ist. Rein
|
||
// ergonomisch — die Serverpruefung bleibt die einzige wirksame Grenze
|
||
// (D-04, T-A1D-02).
|
||
const canManageRow = (user: User) =>
|
||
!(user.role === 'SUPER_ADMIN' && currentUser?.role !== 'SUPER_ADMIN');
|
||
|
||
const roleBadgeClass = (role: string) => {
|
||
switch (role) {
|
||
case 'SUPER_ADMIN':
|
||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||
case 'ADMIN':
|
||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||
default:
|
||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Page header */}
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
|
||
<button
|
||
onClick={openCreate}
|
||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||
>
|
||
{t('create')}
|
||
</button>
|
||
</div>
|
||
|
||
{loadError && (
|
||
<div
|
||
role="alert"
|
||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||
>
|
||
{loadError}
|
||
</div>
|
||
)}
|
||
|
||
{/* Users table */}
|
||
{loading ? (
|
||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||
) : loadError ? null : users.length === 0 ? (
|
||
<p className="text-muted-foreground">{t('noUsers')}</p>
|
||
) : (
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-muted/50">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||
{t('username')}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||
{t('email')}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||
{t('displayName')}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||
{t('role')}
|
||
</th>
|
||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||
{t('status')}
|
||
</th>
|
||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||
{t('actions')}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{users.map((user) => (
|
||
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
|
||
<td className="px-4 py-3 font-medium text-foreground">
|
||
{user.username}
|
||
</td>
|
||
<td className="px-4 py-3 text-muted-foreground">
|
||
{user.email ?? '–'}
|
||
</td>
|
||
<td className="px-4 py-3 text-muted-foreground">
|
||
{user.displayName ?? '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
|
||
>
|
||
{tHeader(`role.${user.role}`)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
|
||
user.isActive
|
||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
|
||
}`}
|
||
>
|
||
{user.isActive ? tCommon('active') : tCommon('inactive')}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={() => setDetailsUser(user)}
|
||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||
>
|
||
{t('grants.detailsButton')}
|
||
</button>
|
||
{canManageRow(user) && (
|
||
<button
|
||
onClick={() => openEdit(user)}
|
||
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
|
||
>
|
||
{tCommon('edit')}
|
||
</button>
|
||
)}
|
||
{canManageRow(user) && (
|
||
<button
|
||
onClick={() => {
|
||
setDeleteConfirm(user.id);
|
||
setDeleteError(null);
|
||
}}
|
||
disabled={user.id === currentUser?.id}
|
||
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
|
||
>
|
||
{tCommon('delete')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create/Edit modal */}
|
||
{showForm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||
<h2 className="text-lg font-bold text-foreground mb-4">
|
||
{editingUser ? t('edit') : t('create')}
|
||
</h2>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
{t('username')}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.username}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, username: e.target.value })
|
||
}
|
||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
{t('email')}
|
||
</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={formData.email}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, email: e.target.value })
|
||
}
|
||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
{t('password')}
|
||
{editingUser && (
|
||
<span className="ml-1 text-xs text-muted-foreground">
|
||
(optional)
|
||
</span>
|
||
)}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
required={!editingUser}
|
||
minLength={8}
|
||
value={formData.password}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, password: e.target.value })
|
||
}
|
||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
{t('displayName')}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.displayName}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, displayName: e.target.value })
|
||
}
|
||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-medium text-foreground">
|
||
{t('role')}
|
||
</label>
|
||
<select
|
||
value={formData.role}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
role: e.target.value as UserFormData['role'],
|
||
})
|
||
}
|
||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
>
|
||
<option value="USER">{tHeader('role.USER')}</option>
|
||
<option value="ADMIN">{tHeader('role.ADMIN')}</option>
|
||
{currentUser?.role === 'SUPER_ADMIN' && (
|
||
<option value="SUPER_ADMIN">
|
||
{tHeader('role.SUPER_ADMIN')}
|
||
</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
{formError && (
|
||
<div
|
||
role="alert"
|
||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||
>
|
||
{formError}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowForm(false)}
|
||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||
>
|
||
{tCommon('cancel')}
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||
>
|
||
{tCommon('save')}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Delete confirmation modal */}
|
||
{deleteConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||
<p className="text-sm text-foreground mb-4">
|
||
{t('deleteConfirm')}
|
||
</p>
|
||
{deleteError && (
|
||
<div
|
||
role="alert"
|
||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
|
||
>
|
||
{deleteError}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
onClick={() => setDeleteConfirm(null)}
|
||
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||
>
|
||
{tCommon('cancel')}
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(deleteConfirm)}
|
||
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
|
||
>
|
||
{tCommon('delete')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */}
|
||
{detailsUser && (
|
||
<UserAccessModal
|
||
userId={detailsUser.id}
|
||
username={detailsUser.displayName ?? detailsUser.username}
|
||
onClose={() => setDetailsUser(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|