Files
tessera-ctl/apps/web/src/app/(portal)/admin/users/page.tsx
T
schalli 30e4bc3a4f style(web): Seiten auf Mosaik-Muster umgestellt
Einheitliche Seitenkoepfe mit Modul-Kachel, Knopf- und Linkklassen
statt gelber Schrift, Karten ohne harte Rahmen, Tabellenkoepfe ohne
Grossbuchstaben, Marktplatz mit Fluent-Reitern, Filterpillen und
Kachel-Karten, Einstellungs-/Verwaltungsnavigation mit Auswahlpille,
leere Zustaende mit Symbol im Kreis, Proxmox ohne leuchtende Schatten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 14:49:01 +02:00

520 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{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
type="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
type="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
type="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 bg-card dark:border dark:border-border 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 htmlFor="user-username" className="text-sm font-medium text-foreground">
{t('username')}
</label>
<input
id="user-username"
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 htmlFor="user-email" className="text-sm font-medium text-foreground">
{t('email')}
</label>
<input
id="user-email"
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 htmlFor="user-password" className="text-sm font-medium text-foreground">
{t('password')}
{editingUser && (
<span className="ml-1 text-xs text-muted-foreground">
(optional)
</span>
)}
</label>
<input
id="user-password"
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 htmlFor="user-display-name" className="text-sm font-medium text-foreground">
{t('displayName')}
</label>
<input
id="user-display-name"
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 htmlFor="user-role" className="text-sm font-medium text-foreground">
{t('role')}
</label>
<select
id="user-role"
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="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
>
{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 bg-card dark:border dark:border-border 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
type="button"
onClick={() => setDeleteConfirm(null)}
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="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>
);
}