32441d77c7
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>
724 lines
26 KiB
TypeScript
724 lines
26 KiB
TypeScript
'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} <{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
|
||
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>
|
||
);
|
||
}
|