Add email invitation system with SMTP configuration and invite token workflow
Implemented comprehensive email invitation functionality for new users. Added SMTP settings management in admin panel with configuration UI for host, port, credentials, sender details, and TLS options. Created invite token system with 48-hour expiration and single-use enforcement. Added mailer module with HTML email templates in German theme styling (radial gradient backgrounds, gold accents, bordered cards). Implemented invite
This commit is contained in:
@@ -25,6 +25,8 @@ import WinnerCard from "./components/WinnerCard";
|
||||
import WinnerBadge from "./components/WinnerBadge";
|
||||
import NewGameModal from "./components/NewGameModal";
|
||||
import StatsModal from "./components/StatsModal";
|
||||
import AdminSettingsModal from "./components/AdminSettingsModal";
|
||||
import InvitePage from "./components/InvitePage";
|
||||
|
||||
export default function App() {
|
||||
useHpGlobalStyles();
|
||||
@@ -62,6 +64,7 @@ export default function App() {
|
||||
const [chipEntry, setChipEntry] = useState(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
|
||||
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
const [pw1, setPw1] = useState("");
|
||||
@@ -94,6 +97,7 @@ export default function App() {
|
||||
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
||||
const [celebrateName, setCelebrateName] = useState("");
|
||||
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
|
||||
const inviteToken = window.location.pathname.startsWith("/invite/") ? decodeURIComponent(window.location.pathname.slice("/invite/".length)) : "";
|
||||
|
||||
// baseline per game: beim ersten Meta-Load NICHT feiern
|
||||
const winnerBaselineRef = useRef(false);
|
||||
@@ -628,6 +632,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
// ===== Login page =====
|
||||
if (inviteToken) {
|
||||
return <InvitePage token={inviteToken} />;
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
<LoginPage
|
||||
@@ -694,6 +702,10 @@ export default function App() {
|
||||
setAdminOpen(true);
|
||||
setUserMenuOpen(false);
|
||||
}}
|
||||
openAdminSettings={() => {
|
||||
setAdminSettingsOpen(true);
|
||||
setUserMenuOpen(false);
|
||||
}}
|
||||
doLogout={doLogout}
|
||||
onOpenNewGame={() => setNewGameOpen(true)}
|
||||
/>
|
||||
@@ -701,6 +713,9 @@ export default function App() {
|
||||
{me.role === "admin" && (
|
||||
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
|
||||
)}
|
||||
{me.role === "admin" && (
|
||||
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
<GamePickerCard
|
||||
games={games}
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +39,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
setEditingUser(null);
|
||||
setForm({ ...emptyForm });
|
||||
setMsg("");
|
||||
setNotice("");
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -51,6 +53,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
disabled: !!user.disabled,
|
||||
});
|
||||
setMsg("");
|
||||
setNotice("");
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -63,7 +66,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
const saveUser = async () => {
|
||||
setMsg("");
|
||||
if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich.");
|
||||
if (!editingUser && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
|
||||
if (!editingUser && form.password && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
|
||||
if (editingUser && form.password && form.password.length < 8) return setMsg("❌ Neues Passwort muss mindestens 8 Zeichen haben.");
|
||||
|
||||
setSaving(true);
|
||||
@@ -76,11 +79,12 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
|
||||
await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
|
||||
const result = await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
|
||||
method: editingUser ? "PATCH" : "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadUsers();
|
||||
setNotice(result.invite_sent ? "✅ Einladung wurde per E-Mail versendet." : "✅ User gespeichert.");
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen."));
|
||||
@@ -118,6 +122,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>Vorhandene User <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div>
|
||||
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button>
|
||||
</div>
|
||||
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
|
||||
|
||||
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
|
||||
{users.map((user) => (
|
||||
@@ -150,10 +155,11 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
||||
<div style={{ marginTop: 18, display: "grid", gap: 10 }}>
|
||||
<label style={styles.adminFieldLabel}>Anzeigename<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder="z. B. Sascha Nesterovic" style={styles.input} autoFocus /></label>
|
||||
<label style={styles.adminFieldLabel}>E-Mail<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label>
|
||||
<label style={styles.adminFieldLabel}>{editingUser ? "Neues Passwort (optional)" : "Passwort"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Mindestens 8 Zeichen"} type="password" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>{editingUser ? "Neues Passwort (optional)" : "Passwort (optional)"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Leer lassen = Einladung senden"} type="password" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>Rolle<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">User</option><option value="admin">Admin</option></select></label>
|
||||
{editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> Konto ist aktiv</label>}
|
||||
{msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>}
|
||||
{!editingUser && <div style={{ color: stylesTokens.textDim, fontSize: 12 }}>Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.</div>}
|
||||
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? "Speichern …" : editingUser ? "Änderungen speichern" : "User erstellen"}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "../api/client";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
const initial = {
|
||||
smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "",
|
||||
smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_use_tls: true,
|
||||
app_base_url: window.location.origin,
|
||||
};
|
||||
|
||||
export default function AdminSettingsModal({ open, onClose }) {
|
||||
const [form, setForm] = useState(initial);
|
||||
const [configured, setConfigured] = useState(false);
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMessage("");
|
||||
api("/admin/settings/smtp").then((data) => {
|
||||
setForm({ ...initial, ...data, smtp_password: "" });
|
||||
setConfigured(!!data.smtp_password_configured);
|
||||
}).catch((error) => setMessage("❌ " + (error?.message || "Laden fehlgeschlagen.")));
|
||||
}, [open]);
|
||||
|
||||
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
const save = async () => {
|
||||
setSaving(true); setMessage("");
|
||||
try {
|
||||
await api("/admin/settings/smtp", { method: "PATCH", body: JSON.stringify(form) });
|
||||
setConfigured(!!form.smtp_password || configured);
|
||||
setMessage("✅ SMTP-Einstellungen gespeichert.");
|
||||
} catch (error) { setMessage("❌ " + (error?.message || "Speichern fehlgeschlagen.")); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
const test = async () => {
|
||||
if (!recipient.trim()) return setMessage("❌ Bitte eine Test-E-Mail angeben.");
|
||||
setTesting(true); setMessage("");
|
||||
try {
|
||||
await api("/admin/settings/smtp/test", { method: "POST", body: JSON.stringify({ recipient }) });
|
||||
setMessage("✅ Test-E-Mail wurde versendet.");
|
||||
} catch (error) { setMessage("❌ " + (error?.message || "Test fehlgeschlagen.")); }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div style={styles.modalOverlay} onMouseDown={onClose}>
|
||||
<div className="hp-admin-settings-card" style={{ ...styles.modalCard, width: "min(620px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div style={styles.modalHeader}>
|
||||
<div><div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Settings</div><div style={{ color: stylesTokens.textDim, fontSize: 12, marginTop: 3 }}>SMTP und Einladungen konfigurieren</div></div>
|
||||
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: "grid", gap: 10 }}>
|
||||
<div className="hp-settings-grid">
|
||||
<label style={styles.adminFieldLabel}>SMTP-Server<input value={form.smtp_host} onChange={(e) => setField("smtp_host", e.target.value)} placeholder="smtp.example.com" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>Port<input type="number" value={form.smtp_port} onChange={(e) => setField("smtp_port", e.target.value)} style={styles.input} /></label>
|
||||
</div>
|
||||
<div className="hp-settings-grid">
|
||||
<label style={styles.adminFieldLabel}>SMTP-Benutzername<input value={form.smtp_username} onChange={(e) => setField("smtp_username", e.target.value)} placeholder="optional" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>Passwort<input type="password" value={form.smtp_password} onChange={(e) => setField("smtp_password", e.target.value)} placeholder={configured ? "•••••••• gespeichert" : "optional"} style={styles.input} /></label>
|
||||
</div>
|
||||
<div className="hp-settings-grid">
|
||||
<label style={styles.adminFieldLabel}>Absender-E-Mail<input type="email" value={form.smtp_from_email} onChange={(e) => setField("smtp_from_email", e.target.value)} placeholder="noreply@example.com" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>Absendername<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label>
|
||||
</div>
|
||||
<label style={styles.adminFieldLabel}>App-URL für Einladungslinks<input value={form.app_base_url} onChange={(e) => setField("app_base_url", e.target.value)} placeholder="https://notizbogen.example.com" style={styles.input} /></label>
|
||||
<label className="hp-admin-check"><input type="checkbox" checked={!!form.smtp_use_tls} onChange={(e) => setField("smtp_use_tls", e.target.checked)} /> STARTTLS verwenden (empfohlen)</label>
|
||||
<div className="hp-settings-test">
|
||||
<label style={{ ...styles.adminFieldLabel, flex: 1 }}>Test-E-Mail<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="du@example.com" style={styles.input} /></label>
|
||||
<button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? "Sende …" : "Test senden"}</button>
|
||||
</div>
|
||||
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
|
||||
<button onClick={save} style={{ ...styles.primaryBtn, width: "100%" }} disabled={saving}>{saving ? "Speichern …" : "Einstellungen speichern"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body
|
||||
);
|
||||
}
|
||||
@@ -169,6 +169,10 @@ export default function HelpModal({ open, onClose }) {
|
||||
<span style={styles.helpMiniTag}>🛡️</span>
|
||||
<div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div>
|
||||
</div>
|
||||
<div style={styles.helpListRow}>
|
||||
<span style={styles.helpMiniTag}>✉</span>
|
||||
<div><b>Admin Settings</b> = SMTP konfigurieren und Einladungen ohne vorgegebenes Passwort versenden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.helpText, marginTop: 16 }}>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function InvitePage({ token }) {
|
||||
const [info, setInfo] = useState(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { api(`/auth/invite/${encodeURIComponent(token)}`).then(setInfo).catch((e) => setMessage("❌ " + (e?.message || "Diese Einladung ist nicht mehr gültig."))); }, [token]);
|
||||
const submit = async (event) => {
|
||||
event.preventDefault(); setMessage("");
|
||||
if (password.length < 8) return setMessage("❌ Das Passwort muss mindestens 8 Zeichen haben.");
|
||||
if (password !== confirm) return setMessage("❌ Die Passwörter stimmen nicht überein.");
|
||||
setSaving(true);
|
||||
try { await api(`/auth/invite/${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ password }) }); setMessage("✅ Passwort gesetzt. Du wirst weitergeleitet …"); setTimeout(() => { window.location.href = "/"; }, 700); }
|
||||
catch (e) { setMessage("❌ " + (e?.message || "Einladung konnte nicht aktiviert werden.")); setSaving(false); }
|
||||
};
|
||||
|
||||
return <div style={styles.loginPage}>
|
||||
<div style={styles.bgFixed} aria-hidden="true"><div style={styles.bgMap} /></div>
|
||||
<main className="hp-invite-card" style={{ ...styles.loginCard, position: "relative", zIndex: 1 }}>
|
||||
<div className="hp-invite-seal">✦</div>
|
||||
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 24 }}>Notizbogen</div>
|
||||
<div style={{ color: stylesTokens.textDim, marginTop: 5 }}>Deine Einladung</div>
|
||||
{info ? <>
|
||||
<div style={{ marginTop: 22, padding: "14px 16px", borderRadius: 14, background: "rgba(233,216,166,.08)", border: "1px solid rgba(233,216,166,.18)", textAlign: "left" }}><div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 18 }}>{info.display_name}</div><div style={{ color: stylesTokens.textDim, fontSize: 13, marginTop: 4 }}>{info.email} · {info.role}</div></div>
|
||||
<form onSubmit={submit} style={{ marginTop: 18, display: "grid", gap: 10, textAlign: "left" }}>
|
||||
<label style={styles.adminFieldLabel}>Passwort setzen<input autoFocus type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mindestens 8 Zeichen" style={styles.input} /></label>
|
||||
<label style={styles.adminFieldLabel}>Passwort bestätigen<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="Passwort wiederholen" style={styles.input} /></label>
|
||||
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
|
||||
<button type="submit" style={{ ...styles.primaryBtn, width: "100%", marginTop: 5 }} disabled={saving}>{saving ? "Wird aktiviert …" : "Einladung annehmen"}</button>
|
||||
</form>
|
||||
</> : <div style={{ marginTop: 24, color: message ? "#ffb3b3" : stylesTokens.textDim }}>{message || "Einladung wird geprüft …"}</div>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export default function TopBar({
|
||||
openDesignModal,
|
||||
openStatsModal,
|
||||
openAdminPanel,
|
||||
openAdminSettings,
|
||||
doLogout,
|
||||
onOpenNewGame,
|
||||
}) {
|
||||
@@ -75,6 +76,15 @@ export default function TopBar({
|
||||
>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openAdminSettings?.();
|
||||
}}
|
||||
style={styles.userDropdownItem}
|
||||
>
|
||||
Admin Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -113,10 +113,17 @@ export function useHpGlobalStyles() {
|
||||
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; }
|
||||
.hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
|
||||
.hp-admin-editor-card { overflow: hidden; }
|
||||
.hp-settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 10px; }
|
||||
.hp-settings-test { display: flex; align-items: end; gap: 10px; }
|
||||
.hp-invite-card { animation: popIn 480ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 24px 80px rgba(0,0,0,.6), 0 0 40px rgba(233,216,166,.08); }
|
||||
.hp-invite-seal { width: 52px; height: 52px; margin: 0 auto 14px; display: grid; place-items: center; border: 1px solid rgba(233,216,166,.42); border-radius: 50%; color: var(--hp-textGold); font-size: 24px; box-shadow: 0 0 24px rgba(233,216,166,.12); }
|
||||
.hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; }
|
||||
.hp-admin-status { color: #baf3c9 !important; font-size: 13px; }
|
||||
.hp-admin-status--disabled { color: #ffb3b3 !important; }
|
||||
@media (max-width: 600px) {
|
||||
.hp-settings-grid { grid-template-columns: 1fr; }
|
||||
.hp-settings-test { align-items: stretch; flex-direction: column; }
|
||||
.hp-settings-test button { width: 100%; }
|
||||
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
|
||||
.hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
|
||||
.hp-topbar > div:first-child { min-width: 0; flex: 1; }
|
||||
@@ -136,6 +143,7 @@ export function useHpGlobalStyles() {
|
||||
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
|
||||
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; }
|
||||
.hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; }
|
||||
.hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
|
||||
.hp-admin-editor-card button { min-height: 40px; }
|
||||
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
|
||||
.hp-row button { min-height: 40px; }
|
||||
|
||||
Reference in New Issue
Block a user