Files
tessera-ctl/apps/web/src/app/(portal)/admin/users/page.tsx
T
schalli 32441d77c7
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00

724 lines
26 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 { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
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;
/** `null` = hat sich noch nie angemeldet (Spalte "Letzte Anmeldung": "Noch nie"). */
lastLoginAt?: string | null;
/** Zeitpunkt der letzten Willkommensmail, `null` = nie gesendet. */
welcomeMailSentAt?: string | null;
}
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 tWelcome = useTranslations('admin.users.welcomeMail');
const locale = useLocale();
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);
// Willkommensmail: Versandweg vorhanden? `null` = noch unbekannt (Knopf
// bleibt bedienbar, die API meldet einen fehlenden Versandweg selbst).
const [welcomeAvailable, setWelcomeAvailable] = useState<boolean | null>(null);
const [welcomeUser, setWelcomeUser] = useState<User | null>(null);
const [welcomeSending, setWelcomeSending] = useState(false);
const [welcomeError, setWelcomeError] = useState<string | null>(null);
const [welcomeSuccess, setWelcomeSuccess] = useState<string | 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]);
useEffect(() => {
if (!hasAccess) return;
let cancelled = false;
fetch(`${API_URL}/users/welcome-mail/status`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as { available?: unknown };
if (!cancelled && typeof body?.available === 'boolean') {
setWelcomeAvailable(body.available);
}
})
.catch(() => {
/* unbekannt lassen — die API prueft beim Versand selbst */
});
return () => {
cancelled = true;
};
}, [hasAccess]);
const formatDateTime = (iso: string) => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
};
/**
* Grund, warum der Knopf "Willkommensmail senden" nicht bedienbar ist,
* oder `null`. Rein ergonomisch — die API prueft dasselbe (400/409).
*/
const welcomeBlockedReason = (user: User): string | null => {
if (!user.email) return tWelcome('noEmail');
if (!user.isActive) return tWelcome('inactive');
if (welcomeAvailable === false) return tWelcome('noSmtp');
return null;
};
const openWelcome = (user: User) => {
setWelcomeUser(user);
setWelcomeError(null);
setWelcomeSuccess(null);
};
const handleSendWelcome = async () => {
if (!welcomeUser) return;
setWelcomeSending(true);
setWelcomeError(null);
try {
const res = await fetch(`${API_URL}/users/${welcomeUser.id}/welcome-mail`, {
method: 'POST',
credentials: 'include',
});
if (res.ok) {
const email = welcomeUser.email ?? '';
setWelcomeUser(null);
setWelcomeSuccess(tWelcome('success', { email }));
fetchUsers();
} else {
const detail = await readApiMessage(res);
setWelcomeError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setWelcomeError(t('errors.network'));
} finally {
setWelcomeSending(false);
}
};
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');
/**
* Willkommensmail: an jeden Benutzer, dessen Zeile die angemeldete Person
* verwalten darf (Zielrollen-Riegel wie beim Bearbeiten). Spiegelt die
* API, die allein entscheidet.
*/
const canSendWelcome = (user: User) => canManageRow(user);
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>
)}
{welcomeSuccess && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
>
<span>{welcomeSuccess}</span>
<button
type="button"
onClick={() => setWelcomeSuccess(null)}
aria-label={tCommon('close')}
className="rounded px-1 text-xs hover:bg-muted"
>
×
</button>
</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-left font-medium text-muted-foreground">
{t('lastLogin')}
</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>
{user.welcomeMailSentAt && (
<div className="mt-1 text-xs text-muted-foreground">
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-0.5">
{canSendWelcome(user) && (
<button
type="button"
onClick={() => openWelcome(user)}
disabled={welcomeBlockedReason(user) !== null}
title={
welcomeBlockedReason(user) ??
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<ActionIcon kind="mail" />
<span className="sr-only">
{user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')}
</span>
</button>
)}
<button
type="button"
onClick={() => setDetailsUser(user)}
title={t('grants.detailsButton')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span>
</button>
{canManageRow(user) && (
<button
type="button"
onClick={() => openEdit(user)}
title={tCommon('edit')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span>
</button>
)}
{canManageRow(user) && (
<button
type="button"
onClick={() => {
setDeleteConfirm(user.id);
setDeleteError(null);
}}
disabled={user.id === currentUser?.id}
title={tCommon('delete')}
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
>
<ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span>
</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>
)}
{/* Willkommensmail: Bestaetigung mit Empfaengeradresse */}
{welcomeUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
role="dialog"
aria-modal="true"
aria-labelledby="welcome-mail-title"
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
>
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
{tWelcome('dialogTitle')}
</h2>
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
<p className="mt-2 mb-4 rounded-md bg-muted px-3 py-2 text-sm font-medium text-foreground break-all">
{welcomeUser.displayName ?? welcomeUser.username} &lt;{welcomeUser.email}&gt;
</p>
{welcomeUser.welcomeMailSentAt && (
<p className="mb-4 text-sm text-muted-foreground">
{tWelcome('dialogResend', { date: formatDateTime(welcomeUser.welcomeMailSentAt) })}
</p>
)}
{welcomeError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive mb-4"
>
{welcomeError}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setWelcomeUser(null)}
disabled={welcomeSending}
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={handleSendWelcome}
disabled={welcomeSending}
className="btn btn-primary"
>
{welcomeSending ? tWelcome('sending') : tWelcome('send')}
</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>
);
}
/**
* Symbole der Zeilenaktionen (quick-260930): die Tabelle wurde mit der
* Spalte "Letzte Anmeldung" und der Willkommensmail zu breit fuer Text-
* Knoepfe. Beschriftung steht an jedem Knopf als unsichtbarer Text (sr-only)
* und als title (Tooltip).
*/
function ActionIcon({ kind }: { kind: 'mail' | 'details' | 'edit' | 'delete' }) {
return (
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{kind === 'mail' && (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</>
)}
{kind === 'details' && (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5" />
<path d="M12 8h.01" />
</>
)}
{kind === 'edit' && (
<>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</>
)}
{kind === 'delete' && (
<>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</>
)}
</svg>
);
}