'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 { 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([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingUser, setEditingUser] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [deleteError, setDeleteError] = useState(null); const [formError, setFormError] = useState(null); const [loadError, setLoadError] = useState(null); const [detailsUser, setDetailsUser] = useState(null); const [formData, setFormData] = useState({ 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 = { 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 (

{tCommon('accessDenied')}

); } // 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 (
{/* Page header */}

{t('title')}

{loadError && (
{loadError}
)} {/* Users table */} {loading ? (

{tCommon('loading')}

) : loadError ? null : users.length === 0 ? (

{t('noUsers')}

) : (
{users.map((user) => ( ))}
{t('username')} {t('email')} {t('displayName')} {t('role')} {t('status')} {t('actions')}
{user.username} {user.email ?? '–'} {user.displayName ?? '-'} {tHeader(`role.${user.role}`)} {user.isActive ? tCommon('active') : tCommon('inactive')}
{canManageRow(user) && ( )} {canManageRow(user) && ( )}
)} {/* Create/Edit modal */} {showForm && (

{editingUser ? t('edit') : t('create')}

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" />
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" />
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" />
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" />
{formError && (
{formError}
)}
)} {/* Delete confirmation modal */} {deleteConfirm && (

{t('deleteConfirm')}

{deleteError && (
{deleteError}
)}
)} {/* User-Detail-Zugriff (D-16): geerbte + direkte Modulfreigaben */} {detailsUser && ( setDetailsUser(null)} /> )}
); }