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>
This commit is contained in:
@@ -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} <{welcomeUser.email}>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
displayName: 'Anzeigename',
|
||||
role: 'Rolle',
|
||||
status: 'Status',
|
||||
lastLogin: 'Letzte Anmeldung',
|
||||
neverLoggedIn: 'Noch nie',
|
||||
actions: 'Aktionen',
|
||||
password: 'Passwort',
|
||||
noUsers: 'Keine Benutzer gefunden',
|
||||
@@ -31,6 +33,20 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
loadFailed: 'Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu.',
|
||||
},
|
||||
},
|
||||
'admin.users.welcomeMail': {
|
||||
button: 'Willkommensmail senden',
|
||||
resend: 'Erneut senden',
|
||||
sentAt: 'Willkommensmail gesendet am {date}',
|
||||
noEmail: 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.',
|
||||
noSmtp: 'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
inactive: 'Das Konto ist deaktiviert.',
|
||||
dialogTitle: 'Willkommensmail senden',
|
||||
dialogText: 'Tessera schickt eine Willkommensmail an:',
|
||||
dialogResend: 'Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.',
|
||||
send: 'Senden',
|
||||
sending: 'Wird gesendet …',
|
||||
success: 'Die Willkommensmail an {email} wurde gesendet.',
|
||||
},
|
||||
header: {
|
||||
role: {
|
||||
SUPER_ADMIN: 'Super-Admin',
|
||||
@@ -47,6 +63,7 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
accessDenied: 'Zugriff verweigert',
|
||||
active: 'Aktiv',
|
||||
inactive: 'Inaktiv',
|
||||
close: 'Schließen',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +86,7 @@ function resolve(ns: string, key: string, params?: Record<string, unknown>): str
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
resolve(ns, key, params),
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
@@ -93,6 +111,8 @@ interface User {
|
||||
isActive: boolean;
|
||||
tenantId: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
welcomeMailSentAt?: string | null;
|
||||
}
|
||||
|
||||
const mockUsers: User[] = [
|
||||
@@ -524,13 +544,13 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Details')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Details' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Bearbeiten')).not.toBeInTheDocument();
|
||||
expect(within(superAdminRow).queryByText('Löschen')).not.toBeInTheDocument();
|
||||
|
||||
const normalRow = screen.getByText('user.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(normalRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(normalRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('bietet einem SUPER_ADMIN in der SUPER_ADMIN-Zeile beide Aktionsknoepfe an', async () => {
|
||||
@@ -544,8 +564,8 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const superAdminRow = screen.getByText('super.null').closest('tr') as HTMLElement;
|
||||
expect(within(superAdminRow).getByText('Bearbeiten')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByText('Löschen')).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||
expect(within(superAdminRow).getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('haelt die bestehende Sperre gegen Selbstloeschung unveraendert (ADMIN u1)', async () => {
|
||||
@@ -559,8 +579,165 @@ describe('AdminUsersPage — Aktionsknoepfe der SUPER_ADMIN-Zeile (WINDOWS #36,
|
||||
});
|
||||
|
||||
const ownRow = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
const ownDeleteButton = within(ownRow).getByText('Löschen');
|
||||
const ownDeleteButton = within(ownRow).getByRole('button', { name: 'Löschen' });
|
||||
expect(ownDeleteButton).toBeInTheDocument();
|
||||
expect(ownDeleteButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminUsersPage — Willkommensmail', () => {
|
||||
const base = {
|
||||
role: 'USER' as const,
|
||||
isActive: true,
|
||||
tenantId: 't1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
welcomeMailSentAt: null,
|
||||
};
|
||||
const welcomeUsers: User[] = [
|
||||
{ ...base, id: 'u1', username: 'admin.eins', email: 'admin.eins@ctl.de', displayName: 'Admin Eins', role: 'ADMIN', lastLoginAt: '2026-09-01T08:00:00.000Z' },
|
||||
{ ...base, id: 'u2', username: 'neu.zwei', email: 'neu.zwei@ctl.de', displayName: 'Neu Zwei', lastLoginAt: null },
|
||||
{ ...base, id: 'u3', username: 'ohne.adresse', email: null, displayName: null, lastLoginAt: null },
|
||||
{ ...base, id: 'u4', username: 'schon.da', email: 'schon.da@ctl.de', displayName: null, lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
{ ...base, id: 'u5', username: 'super.fuenf', email: 'super@ctl.de', displayName: null, role: 'SUPER_ADMIN', lastLoginAt: '2026-09-02T08:00:00.000Z' },
|
||||
];
|
||||
|
||||
function stubFetch(opts: { available?: boolean; post?: () => Promise<unknown> } = {}) {
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/users/welcome-mail/status')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ available: opts.available ?? true }) });
|
||||
}
|
||||
if (url.endsWith('/users') && (!init || init.method === undefined)) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(welcomeUsers) });
|
||||
}
|
||||
if (url.endsWith('/welcome-mail') && init?.method === 'POST') {
|
||||
return (opts.post ?? (() => Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) })))();
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it('Knopf bei jedem verwaltbaren Benutzer, auch nach einer Anmeldung; SUPER_ADMIN-Konto fuer einen ADMIN nie; ohne Adresse deaktiviert', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowDa = screen.getByText('schon.da').closest('tr') as HTMLElement;
|
||||
expect(within(rowDa).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeEnabled();
|
||||
|
||||
// SUPER_ADMIN-Konto: fuer einen ADMIN gesperrt wie beim Bearbeiten
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).queryByText('Willkommensmail senden')).not.toBeInTheDocument();
|
||||
|
||||
const rowOhne = screen.getByText('ohne.adresse').closest('tr') as HTMLElement;
|
||||
const btn = within(rowOhne).getByRole('button', { name: 'Willkommensmail senden' });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute('title', 'Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.');
|
||||
});
|
||||
|
||||
it('Spalte "Letzte Anmeldung": Datum/Uhrzeit lokal formatiert, sonst "Noch nie"', async () => {
|
||||
stubAdmin();
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
expect(screen.getByText('Letzte Anmeldung')).toBeInTheDocument();
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByText('Noch nie')).toBeInTheDocument();
|
||||
|
||||
const expected = new Intl.DateTimeFormat('de', { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date('2026-09-01T08:00:00.000Z'),
|
||||
);
|
||||
const rowAdmin = screen.getByText('admin.eins').closest('tr') as HTMLElement;
|
||||
expect(within(rowAdmin).getByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ohne eingerichtetes SMTP ist der Knopf deaktiviert und nennt den Grund', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({ available: false });
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await waitFor(() => expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toBeDisabled());
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' })).toHaveAttribute(
|
||||
'title',
|
||||
'Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).',
|
||||
);
|
||||
});
|
||||
|
||||
it('Dialog nennt den Empfaenger; Senden ruft POST /users/:id/welcome-mail und zeigt die Erfolgsmeldung', async () => {
|
||||
stubAdmin();
|
||||
const fetchMock = stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).getByText(/neu\.zwei@ctl\.de/)).toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Die Willkommensmail an neu.zwei@ctl.de wurde gesendet.')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === 'POST');
|
||||
expect(post?.[0]).toMatch(/\/users\/u2\/welcome-mail$/);
|
||||
});
|
||||
|
||||
it('bei einer Ablehnung (409) bleibt der Dialog offen und zeigt den Servertext', async () => {
|
||||
stubAdmin();
|
||||
stubFetch({
|
||||
post: () =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: () => Promise.resolve({ message: 'Dieser Benutzer hat sich bereits angemeldet.' }),
|
||||
}),
|
||||
});
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
await userEvent.click(within(rowNeu).getByRole('button', { name: 'Willkommensmail senden' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await userEvent.click(within(dialog).getByText('Senden'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Der Server hat die Aktion abgelehnt: Dieser Benutzer hat sich bereits angemeldet.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('nach einem Versand heisst der Knopf "Erneut senden" und die Zeile nennt das Datum', async () => {
|
||||
stubAdmin();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: '2026-09-29T10:15:00.000Z' };
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('neu.zwei')).toBeInTheDocument());
|
||||
const rowNeu = screen.getByText('neu.zwei').closest('tr') as HTMLElement;
|
||||
expect(within(rowNeu).getByRole('button', { name: 'Erneut senden' })).toBeInTheDocument();
|
||||
expect(within(rowNeu).getByText(/Willkommensmail gesendet am/)).toBeInTheDocument();
|
||||
welcomeUsers[1] = { ...welcomeUsers[1], welcomeMailSentAt: null };
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN sieht den Knopf auch bei einem angemeldeten SUPER_ADMIN-Konto', async () => {
|
||||
stubAdmin('u9', 'SUPER_ADMIN');
|
||||
stubFetch();
|
||||
render(<AdminUsersPage />);
|
||||
await waitFor(() => expect(screen.getByText('super.fuenf')).toBeInTheDocument());
|
||||
const rowSuper = screen.getByText('super.fuenf').closest('tr') as HTMLElement;
|
||||
expect(within(rowSuper).getByRole('button', { name: 'Willkommensmail senden' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Anzeigename",
|
||||
"role": "Rolle",
|
||||
"status": "Status",
|
||||
"lastLogin": "Letzte Anmeldung",
|
||||
"neverLoggedIn": "Noch nie",
|
||||
"actions": "Aktionen",
|
||||
"password": "Passwort",
|
||||
"noUsers": "Keine Benutzer gefunden",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "Die Aktion konnte nicht durchgeführt werden. Bitte erneut versuchen.",
|
||||
"network": "Der Server ist nicht erreichbar. Bitte erneut versuchen.",
|
||||
"loadFailed": "Die Benutzerliste konnte nicht geladen werden. Bitte laden Sie die Seite neu."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Willkommensmail senden",
|
||||
"resend": "Erneut senden",
|
||||
"sentAt": "Willkommensmail gesendet am {date}",
|
||||
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.",
|
||||
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).",
|
||||
"inactive": "Das Konto ist deaktiviert.",
|
||||
"dialogTitle": "Willkommensmail senden",
|
||||
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:",
|
||||
"dialogResend": "Eine Willkommensmail wurde bereits am {date} gesendet. Sie wird erneut verschickt.",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet …",
|
||||
"success": "Die Willkommensmail an {email} wurde gesendet."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"displayName": "Display Name",
|
||||
"role": "Role",
|
||||
"status": "Status",
|
||||
"lastLogin": "Last sign-in",
|
||||
"neverLoggedIn": "Never",
|
||||
"actions": "Actions",
|
||||
"password": "Password",
|
||||
"noUsers": "No users found",
|
||||
@@ -557,6 +559,20 @@
|
||||
"generic": "The action could not be completed. Please try again.",
|
||||
"network": "The server is not reachable. Please try again.",
|
||||
"loadFailed": "The user list could not be loaded. Please reload the page."
|
||||
},
|
||||
"welcomeMail": {
|
||||
"button": "Send welcome email",
|
||||
"resend": "Send again",
|
||||
"sentAt": "Welcome email sent on {date}",
|
||||
"noEmail": "No email address on file – please add one first.",
|
||||
"noSmtp": "No SMTP server has been set up yet (Administrator → SMTP).",
|
||||
"inactive": "The account is deactivated.",
|
||||
"dialogTitle": "Send welcome email",
|
||||
"dialogText": "Tessera will send a welcome email with the address, username and first sign-in instructions to:",
|
||||
"dialogResend": "A welcome email was already sent on {date}. It will be sent again.",
|
||||
"send": "Send",
|
||||
"sending": "Sending …",
|
||||
"success": "The welcome email to {email} has been sent."
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
|
||||
Reference in New Issue
Block a user