feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
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

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>
This commit is contained in:
2026-09-30 15:28:52 +02:00
parent 0b34e82b21
commit 32441d77c7
24 changed files with 1540 additions and 115 deletions
+262 -58
View File
@@ -1,7 +1,7 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal';
@@ -19,6 +19,10 @@ interface 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 {
@@ -57,6 +61,8 @@ 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[]>([]);
@@ -68,6 +74,13 @@ export default function AdminUsersPage() {
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: '',
@@ -77,8 +90,7 @@ export default function AdminUsersPage() {
});
// Access check: only ADMIN and SUPER_ADMIN
const hasAccess =
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchUsers = useCallback(async () => {
setLoadError(null);
@@ -106,6 +118,73 @@ export default function AdminUsersPage() {
}
}, [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({
@@ -136,9 +215,7 @@ export default function AdminUsersPage() {
e.preventDefault();
setFormError(null);
const url = editingUser
? `${API_URL}/users/${editingUser.id}`
: `${API_URL}/users`;
const url = editingUser ? `${API_URL}/users/${editingUser.id}` : `${API_URL}/users`;
const method = editingUser ? 'PATCH' : 'POST';
const body: Record<string, unknown> = {
@@ -166,9 +243,7 @@ export default function AdminUsersPage() {
fetchUsers();
} else {
const detail = await readApiMessage(res);
setFormError(
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
setFormError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setFormError(t('errors.network'));
@@ -187,9 +262,7 @@ export default function AdminUsersPage() {
fetchUsers();
} else {
const detail = await readApiMessage(res);
setDeleteError(
detail ? t('errors.serverRejected', { detail }) : t('errors.generic'),
);
setDeleteError(detail ? t('errors.serverRejected', { detail }) : t('errors.generic'));
}
} catch {
setDeleteError(t('errors.network'));
@@ -212,6 +285,13 @@ export default function AdminUsersPage() {
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':
@@ -228,11 +308,7 @@ export default function AdminUsersPage() {
{/* 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"
>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
@@ -246,6 +322,23 @@ export default function AdminUsersPage() {
</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>
@@ -271,6 +364,9 @@ export default function AdminUsersPage() {
<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>
@@ -279,15 +375,9 @@ export default function AdminUsersPage() {
<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 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)}`}
@@ -305,23 +395,52 @@ export default function AdminUsersPage() {
>
{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-2">
<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)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
title={t('grants.detailsButton')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
{t('grants.detailsButton')}
<ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span>
</button>
{canManageRow(user) && (
<button
type="button"
onClick={() => openEdit(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
title={tCommon('edit')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
{tCommon('edit')}
<ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span>
</button>
)}
{canManageRow(user) && (
@@ -332,9 +451,11 @@ export default function AdminUsersPage() {
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"
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"
>
{tCommon('delete')}
<ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span>
</button>
)}
</div>
@@ -363,9 +484,7 @@ export default function AdminUsersPage() {
type="text"
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
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>
@@ -378,9 +497,7 @@ export default function AdminUsersPage() {
type="email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
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>
@@ -388,9 +505,7 @@ export default function AdminUsersPage() {
<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>
<span className="ml-1 text-xs text-muted-foreground">(optional)</span>
)}
</label>
<input
@@ -399,9 +514,7 @@ export default function AdminUsersPage() {
required={!editingUser}
minLength={8}
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
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>
@@ -413,9 +526,7 @@ export default function AdminUsersPage() {
id="user-display-name"
type="text"
value={formData.displayName}
onChange={(e) =>
setFormData({ ...formData, displayName: e.target.value })
}
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>
@@ -437,9 +548,7 @@ export default function AdminUsersPage() {
<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>
<option value="SUPER_ADMIN">{tHeader('role.SUPER_ADMIN')}</option>
)}
</select>
</div>
@@ -459,10 +568,7 @@ export default function AdminUsersPage() {
>
{tCommon('cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
>
<button type="submit" className="btn btn-primary">
{tCommon('save')}
</button>
</div>
@@ -475,9 +581,7 @@ export default function AdminUsersPage() {
{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>
<p className="text-sm text-foreground mb-4">{t('deleteConfirm')}</p>
{deleteError && (
<div
role="alert"
@@ -506,6 +610,57 @@ export default function AdminUsersPage() {
</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
@@ -517,3 +672,52 @@ export default function AdminUsersPage() {
</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>
);
}