feat(admin): Kontoverwaltung und Admin-Einstellungen
"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen, umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden. Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort gesetzt ist - sonst waere das Konto ausgesperrt. Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben, Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls nicht verfuegbar sind. Schutzregeln: - Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein Konto existiert. - Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert oder geloescht werden. - Das eigene Konto laesst sich nicht loeschen oder deaktivieren. - Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des Kontos. Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch sie nicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import {
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||
|
||||
export interface PasskeyEntry {
|
||||
id: string;
|
||||
name: string | null;
|
||||
deviceType: string | null;
|
||||
backedUp: boolean;
|
||||
createdAt: string | Date;
|
||||
lastUsedAt: string | Date | null;
|
||||
}
|
||||
|
||||
export interface SessionEntry {
|
||||
id: string;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
createdAt: string | Date;
|
||||
lastSeenAt: string | Date;
|
||||
}
|
||||
|
||||
export interface AccountData {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: "ADMIN" | "USER";
|
||||
hasPassword: boolean;
|
||||
passkeys: PasskeyEntry[];
|
||||
sessions: SessionEntry[];
|
||||
passkeysAvailable: boolean;
|
||||
}
|
||||
|
||||
type Notice = { ok: boolean; text: string } | null;
|
||||
|
||||
export function AccountManager({ data }: { data: AccountData }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProfileSection data={data} />
|
||||
<PasswordSection hasPassword={data.hasPassword} />
|
||||
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
||||
<SessionSection sessions={data.sessions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: typeof KeyRound;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeLine({ notice }: { notice: Notice }) {
|
||||
if (!notice) return null;
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||
notice.ok
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
)}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection({ data }: { data: AccountData }) {
|
||||
const router = useRouter();
|
||||
const [displayName, setDisplayName] = useState(data.displayName ?? "");
|
||||
const [email, setEmail] = useState(data.email ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName, email }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
||||
setNotice({ ok: true, text: "Profil gespeichert." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Profil" icon={Pencil}>
|
||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="field-label">Benutzername</span>
|
||||
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-600">
|
||||
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
|
||||
</p>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="account-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="account-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Speichern
|
||||
</button>
|
||||
<NoticeLine notice={notice} />
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection({ hasPassword }: { hasPassword: boolean }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (newPassword !== repeat) {
|
||||
setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen.");
|
||||
setNotice({
|
||||
ok: true,
|
||||
text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.",
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setRepeat("");
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
|
||||
icon={KeyRound}
|
||||
description={
|
||||
hasPassword
|
||||
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
|
||||
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
|
||||
}
|
||||
>
|
||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||
{hasPassword ? (
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="account-current">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input
|
||||
id="account-current"
|
||||
className="field"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-new">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="account-new"
|
||||
className="field"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-repeat">
|
||||
Wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="account-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
|
||||
</button>
|
||||
<NoticeLine notice={notice} />
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeySection({
|
||||
passkeys,
|
||||
available,
|
||||
}: {
|
||||
passkeys: PasskeyEntry[];
|
||||
available: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function addPasskey() {
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const optionsResponse = await fetch("/api/auth/passkey/register/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden.");
|
||||
}
|
||||
|
||||
const attestation = await startRegistration({ optionsJSON: optionsPayload.options });
|
||||
|
||||
const suggestion =
|
||||
typeof navigator !== "undefined" && /mac/i.test(navigator.platform)
|
||||
? "Mac"
|
||||
: typeof navigator !== "undefined" && /win/i.test(navigator.platform)
|
||||
? "Windows"
|
||||
: "Geraet";
|
||||
|
||||
const verifyResponse = await fetch("/api/auth/passkey/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: attestation, name: suggestion }),
|
||||
});
|
||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
||||
}
|
||||
|
||||
setNotice({ ok: true, text: "Passkey hinzugefuegt." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(entry: PasskeyEntry) {
|
||||
const name = window.prompt("Name des Passkeys", entry.name ?? "");
|
||||
if (name === null) return;
|
||||
setBusyId(entry.id);
|
||||
await fetch(`/api/account/passkeys/${entry.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}).catch(() => undefined);
|
||||
setBusyId(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function remove(entry: PasskeyEntry) {
|
||||
if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return;
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Passkeys"
|
||||
icon={Fingerprint}
|
||||
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
|
||||
>
|
||||
{!available ? (
|
||||
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
||||
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
|
||||
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
|
||||
oder auf http://localhost zeigen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
|
||||
) : (
|
||||
passkeys.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
|
||||
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
|
||||
{entry.name ?? "Unbenannter Passkey"}
|
||||
{entry.backedUp ? (
|
||||
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
|
||||
synchronisiert
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
angelegt {formatDateTime(entry.createdAt)}
|
||||
{entry.lastUsedAt
|
||||
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
|
||||
: " · noch nie genutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{busyId === entry.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rename(entry)}
|
||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
|
||||
aria-label="Umbenennen"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(entry)}
|
||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Passkey hinzufuegen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NoticeLine notice={notice} />
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSection({ sessions }: { sessions: SessionEntry[] }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logoutEverywhere() {
|
||||
if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return;
|
||||
setBusy(true);
|
||||
await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined);
|
||||
setBusy(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Aktive Sitzungen"
|
||||
icon={Monitor}
|
||||
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="glass-soft px-3 py-2.5">
|
||||
<p className="truncate text-sm text-slate-300">
|
||||
{shortenUserAgent(session.userAgent)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
|
||||
angemeldet {formatDateTime(session.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger mt-4"
|
||||
onClick={logoutEverywhere}
|
||||
disabled={busy || sessions.length <= 1}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
|
||||
Ueberall abmelden
|
||||
</button>
|
||||
{sessions.length <= 1 ? (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Nur dieses Geraet ist angemeldet.
|
||||
</p>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */
|
||||
function shortenUserAgent(userAgent: string | null): string {
|
||||
if (!userAgent) return "Unbekanntes Geraet";
|
||||
|
||||
const browser =
|
||||
/Edg\//.test(userAgent) ? "Edge"
|
||||
: /OPR\//.test(userAgent) ? "Opera"
|
||||
: /Firefox\//.test(userAgent) ? "Firefox"
|
||||
: /Chrome\//.test(userAgent) ? "Chrome"
|
||||
: /Safari\//.test(userAgent) ? "Safari"
|
||||
: "Browser";
|
||||
|
||||
const platform =
|
||||
/Windows/.test(userAgent) ? "Windows"
|
||||
: /Macintosh|Mac OS/.test(userAgent) ? "macOS"
|
||||
: /Android/.test(userAgent) ? "Android"
|
||||
: /iPhone|iPad/.test(userAgent) ? "iOS"
|
||||
: /Linux/.test(userAgent) ? "Linux"
|
||||
: "";
|
||||
|
||||
return platform ? `${browser} auf ${platform}` : browser;
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Fingerprint,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||
|
||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
||||
type Role = "ADMIN" | "USER";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: Role;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | Date | null;
|
||||
lockedUntil: string | Date | null;
|
||||
createdAt: string | Date;
|
||||
_count: { credentials: number; projects: number; sessions: number };
|
||||
}
|
||||
|
||||
export interface AdminData {
|
||||
mode: Mode;
|
||||
allowRegistration: boolean;
|
||||
users: AdminUser[];
|
||||
currentUserId: string;
|
||||
appUrl: string;
|
||||
passkeysAvailable: boolean;
|
||||
}
|
||||
|
||||
type Notice = { ok: boolean; text: string } | null;
|
||||
|
||||
export function AdminManager({ data }: { data: AdminData }) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>(data.mode);
|
||||
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
||||
|
||||
async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) {
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
||||
|
||||
setMode(body.settings.mode);
|
||||
setAllowRegistration(body.settings.allowRegistration);
|
||||
setNotice({ ok: true, text: "Einstellungen gespeichert." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
// Umschalter zuruecksetzen, wenn der Server ablehnt.
|
||||
setMode(data.mode);
|
||||
setAllowRegistration(data.allowRegistration);
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Betriebsart ─────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||
<Users className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Betriebsart
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={mode === "SINGLE_USER"}
|
||||
disabled={busy}
|
||||
icon={User}
|
||||
title="Einzelbetrieb"
|
||||
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
|
||||
onClick={() => {
|
||||
setMode("SINGLE_USER");
|
||||
void patchSettings({ mode: "SINGLE_USER" });
|
||||
}}
|
||||
/>
|
||||
<ModeCard
|
||||
active={mode === "MULTI_USER"}
|
||||
disabled={busy}
|
||||
icon={Users}
|
||||
title="Mehrbenutzerbetrieb"
|
||||
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
|
||||
onClick={() => {
|
||||
setMode("MULTI_USER");
|
||||
void patchSettings({ mode: "MULTI_USER" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "MULTI_USER" ? (
|
||||
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRegistration}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setAllowRegistration(event.target.checked);
|
||||
void patchSettings({ allowRegistration: event.target.checked });
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
|
||||
Internet erreichbaren Instanz besser aus lassen.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||
notice.ok
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
)}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Instanz ─────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Instanz
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
|
||||
lassen sich nur dort aendern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
|
||||
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
|
||||
</div>
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
|
||||
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
|
||||
{data.passkeysAvailable ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||
verfuegbar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ban className="h-3.5 w-3.5 text-amber-300" />
|
||||
kein sicherer Kontext
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* ── Benutzer ────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
|
||||
<UserCog className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Benutzer ({data.users.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary px-3 py-1.5 text-xs"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={mode === "SINGLE_USER"}
|
||||
title={
|
||||
mode === "SINGLE_USER"
|
||||
? "Erst in den Mehrbenutzerbetrieb wechseln"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Konto anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
isSelf={user.id === data.currentUserId}
|
||||
onEdit={() => setEditing(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserDialog
|
||||
open={createOpen}
|
||||
mode="create"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
/>
|
||||
<UserDialog
|
||||
open={editing !== null}
|
||||
mode="edit"
|
||||
user={editing}
|
||||
isSelf={editing?.id === data.currentUserId}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
icon: typeof User;
|
||||
title: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
|
||||
active
|
||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
||||
{title}
|
||||
{active ? (
|
||||
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
onEdit,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
isSelf: boolean;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked =
|
||||
user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now();
|
||||
|
||||
async function remove() {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-soft px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
|
||||
user.isActive
|
||||
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
|
||||
: "bg-slate-700/60",
|
||||
)}
|
||||
>
|
||||
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
|
||||
<span className="truncate">{user.displayName ?? user.username}</span>
|
||||
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
|
||||
{user.role === "ADMIN" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||||
<ShieldCheck className="h-2.5 w-2.5" />
|
||||
Admin
|
||||
</span>
|
||||
) : null}
|
||||
{isSelf ? (
|
||||
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
du
|
||||
</span>
|
||||
) : null}
|
||||
{!user.isActive ? (
|
||||
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
|
||||
deaktiviert
|
||||
</span>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
gesperrt
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
{user._count.projects} Projekte
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Fingerprint className="h-3 w-3" />
|
||||
{user._count.credentials} Passkeys
|
||||
</span>
|
||||
<span suppressHydrationWarning>
|
||||
{user.lastLoginAt
|
||||
? `zuletzt ${formatRelative(user.lastLoginAt)}`
|
||||
: "noch nie angemeldet"}
|
||||
</span>
|
||||
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||
disabled={busy}
|
||||
>
|
||||
<UserCog className="h-3.5 w-3.5" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="btn btn-danger px-2.5 py-1.5 text-xs"
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDialog({
|
||||
open,
|
||||
mode,
|
||||
user,
|
||||
isSelf,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
user?: AdminUser | null;
|
||||
isSelf?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>("USER");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
||||
|
||||
// Formular beim Oeffnen einmalig aus dem Datensatz fuellen.
|
||||
const key = open ? (user?.id ?? "new") : null;
|
||||
if (key !== null && key !== loadedFor) {
|
||||
setLoadedFor(key);
|
||||
setUsername(user?.username ?? "");
|
||||
setDisplayName(user?.displayName ?? "");
|
||||
setEmail(user?.email ?? "");
|
||||
setPassword("");
|
||||
setRole(user?.role ?? "USER");
|
||||
setIsActive(user?.isActive ?? true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const body =
|
||||
mode === "create"
|
||||
? { username, displayName, email, password, role }
|
||||
: { displayName, email, role, isActive, password: password || null };
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`,
|
||||
{
|
||||
method: mode === "create" ? "POST" : "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
||||
}
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
}}
|
||||
title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`}
|
||||
description={
|
||||
mode === "create"
|
||||
? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen."
|
||||
: "Ein neues Passwort beendet alle Sitzungen dieses Kontos."
|
||||
}
|
||||
width="md"
|
||||
>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{mode === "create" ? (
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="admin-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-password">
|
||||
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
|
||||
required={mode === "create"}
|
||||
/>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="field-label">Rolle</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["USER", "ADMIN"] as Role[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setRole(value)}
|
||||
disabled={isSelf && value === "USER"}
|
||||
title={
|
||||
isSelf && value === "USER"
|
||||
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
|
||||
role === value
|
||||
? "border-violet-400/60 bg-violet-500/12 text-white"
|
||||
: "border-white/9 text-slate-400 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
{value === "ADMIN" ? "Administrator" : "Benutzer"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "edit" ? (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(event) => setIsActive(event.target.checked)}
|
||||
disabled={isSelf}
|
||||
className="h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
Konto aktiv
|
||||
{isSelf ? (
|
||||
<span className="text-[11px] text-slate-600">
|
||||
(das eigene Konto laesst sich nicht deaktivieren)
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user