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:
@@ -0,0 +1,63 @@
|
|||||||
|
import html
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
|
||||||
|
from .models import AppSettings, User
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_ready(settings: AppSettings | None) -> bool:
|
||||||
|
return bool(settings and settings.smtp_host and settings.smtp_from_email)
|
||||||
|
|
||||||
|
|
||||||
|
def send_html_email(settings: AppSettings, recipient: str, subject: str, body_html: str, body_text: str):
|
||||||
|
if not _settings_ready(settings):
|
||||||
|
raise ValueError("SMTP settings are incomplete")
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = formataddr((settings.smtp_from_name or "Cluedo HP", settings.smtp_from_email))
|
||||||
|
message["To"] = recipient
|
||||||
|
message.set_content(body_text)
|
||||||
|
message.add_alternative(body_html, subtype="html")
|
||||||
|
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server:
|
||||||
|
server.ehlo()
|
||||||
|
if settings.smtp_use_tls:
|
||||||
|
server.starttls(context=context)
|
||||||
|
server.ehlo()
|
||||||
|
if settings.smtp_username:
|
||||||
|
server.login(settings.smtp_username, settings.smtp_password)
|
||||||
|
server.send_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
def send_user_invite(settings: AppSettings, user: User, invite_url: str):
|
||||||
|
name = html.escape(user.display_name or user.email)
|
||||||
|
safe_url = html.escape(invite_url, quote=True)
|
||||||
|
from_name = html.escape(settings.smtp_from_name or "Cluedo HP")
|
||||||
|
body_html = f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html><body style="margin:0;background:#0b0b0f;color:#f5efdc;font-family:Georgia,serif;">
|
||||||
|
<div style="padding:36px 16px;background:radial-gradient(circle at top,#312b24 0,#0b0b0f 58%);">
|
||||||
|
<div style="max-width:580px;margin:0 auto;border:1px solid #6f603f;border-radius:22px;overflow:hidden;background:#17161b;box-shadow:0 18px 55px rgba(0,0,0,.55);">
|
||||||
|
<div style="padding:28px 30px;text-align:center;background:linear-gradient(135deg,#29231c,#141319);border-bottom:1px solid #6f603f;">
|
||||||
|
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
|
||||||
|
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:30px;line-height:1.55;">
|
||||||
|
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div>
|
||||||
|
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
|
||||||
|
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p>
|
||||||
|
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
|
||||||
|
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
|
||||||
|
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 30px;color:#8e8777;font-size:12px;border-top:1px solid #302c2b;text-align:center;">Deine Notizen bleiben privat.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig."
|
||||||
|
send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text)
|
||||||
@@ -12,6 +12,7 @@ from .routes.auth import router as auth_router
|
|||||||
from .routes.admin import router as admin_router
|
from .routes.admin import router as admin_router
|
||||||
from .routes.games import router as games_router
|
from .routes.games import router as games_router
|
||||||
from .routes.setup import router as setup_router
|
from .routes.setup import router as setup_router
|
||||||
|
from .routes.invites import router as invites_router
|
||||||
|
|
||||||
app = FastAPI(title="Cluedo Sheet")
|
app = FastAPI(title="Cluedo Sheet")
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ app.include_router(auth_router)
|
|||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(games_router)
|
app.include_router(games_router)
|
||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
|
app.include_router(invites_router)
|
||||||
|
|
||||||
|
|
||||||
def _rand_join_code(n: int = 6) -> str:
|
def _rand_join_code(n: int = 6) -> str:
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ class User(Base):
|
|||||||
display_name: Mapped[str] = mapped_column(String, default="")
|
display_name: Mapped[str] = mapped_column(String, default="")
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(Base):
|
||||||
|
__tablename__ = "app_settings"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default="default")
|
||||||
|
smtp_host: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
|
||||||
|
smtp_username: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_password: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_from_email: Mapped[str] = mapped_column(String, default="")
|
||||||
|
smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP")
|
||||||
|
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
|
||||||
|
|
||||||
|
|
||||||
|
class InviteToken(Base):
|
||||||
|
__tablename__ = "invite_tokens"
|
||||||
|
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String, index=True)
|
||||||
|
expires_at: Mapped[str] = mapped_column(DateTime(timezone=True))
|
||||||
|
used_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class Game(Base):
|
class Game(Base):
|
||||||
__tablename__ = "games"
|
__tablename__ = "games"
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|||||||
+111
-6
@@ -1,7 +1,12 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import User, Role
|
from ..mailer import send_html_email, send_user_invite
|
||||||
|
from ..models import AppSettings, InviteToken, User, Role
|
||||||
from ..security import hash_password, get_session_user_id
|
from ..security import hash_password, get_session_user_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
@@ -37,18 +42,47 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
|||||||
password = data.get("password") or ""
|
password = data.get("password") or ""
|
||||||
display_name = (data.get("display_name") or "").strip()
|
display_name = (data.get("display_name") or "").strip()
|
||||||
|
|
||||||
if not email or not password:
|
if not email or not display_name:
|
||||||
raise HTTPException(400, "email/password required")
|
raise HTTPException(400, "name/email required")
|
||||||
if db.query(User).filter(User.email == email).first():
|
if db.query(User).filter(User.email == email).first():
|
||||||
raise HTTPException(409, "email exists")
|
raise HTTPException(409, "email exists")
|
||||||
|
|
||||||
role = data.get("role") or Role.user.value
|
role = data.get("role")
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(400, "role required")
|
||||||
if role not in (Role.admin.value, Role.user.value):
|
if role not in (Role.admin.value, Role.user.value):
|
||||||
raise HTTPException(400, "invalid role")
|
raise HTTPException(400, "invalid role")
|
||||||
|
|
||||||
|
settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default")
|
||||||
|
invite_sent = not password
|
||||||
|
raw_token = None
|
||||||
|
|
||||||
|
if invite_sent:
|
||||||
|
if not settings.smtp_host or not settings.smtp_from_email:
|
||||||
|
raise HTTPException(400, "configure SMTP settings before sending invites")
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
password = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
|
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
|
||||||
db.add(u); db.commit()
|
db.add(u)
|
||||||
return {"ok": True, "id": u.id}
|
db.flush()
|
||||||
|
|
||||||
|
if invite_sent:
|
||||||
|
invite = InviteToken(
|
||||||
|
user_id=u.id,
|
||||||
|
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=48),
|
||||||
|
)
|
||||||
|
db.add(invite)
|
||||||
|
invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}"
|
||||||
|
try:
|
||||||
|
send_user_invite(settings, u, invite_url)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(502, f"invite email could not be sent: {exc}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
|
||||||
|
|
||||||
@router.delete("/users/{user_id}")
|
@router.delete("/users/{user_id}")
|
||||||
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
|
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
|
||||||
@@ -106,3 +140,74 @@ def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(ge
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(db: Session) -> AppSettings:
|
||||||
|
settings = db.query(AppSettings).filter(AppSettings.id == "default").first()
|
||||||
|
if not settings:
|
||||||
|
settings = AppSettings(id="default")
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(settings)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings/smtp")
|
||||||
|
def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
|
||||||
|
require_admin(req, db)
|
||||||
|
settings = get_settings(db)
|
||||||
|
return {
|
||||||
|
"smtp_host": settings.smtp_host,
|
||||||
|
"smtp_port": settings.smtp_port,
|
||||||
|
"smtp_username": settings.smtp_username,
|
||||||
|
"smtp_password_configured": bool(settings.smtp_password),
|
||||||
|
"smtp_from_email": settings.smtp_from_email,
|
||||||
|
"smtp_from_name": settings.smtp_from_name,
|
||||||
|
"smtp_use_tls": settings.smtp_use_tls,
|
||||||
|
"app_base_url": settings.app_base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/settings/smtp")
|
||||||
|
def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
|
require_admin(req, db)
|
||||||
|
settings = get_settings(db)
|
||||||
|
|
||||||
|
settings.smtp_host = (data.get("smtp_host") or "").strip()
|
||||||
|
try:
|
||||||
|
settings.smtp_port = int(data.get("smtp_port") or 587)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "SMTP port must be a number")
|
||||||
|
settings.smtp_username = (data.get("smtp_username") or "").strip()
|
||||||
|
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
|
||||||
|
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
|
||||||
|
settings.smtp_use_tls = bool(data.get("smtp_use_tls", True))
|
||||||
|
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
|
||||||
|
if "smtp_password" in data and data.get("smtp_password"):
|
||||||
|
settings.smtp_password = data["smtp_password"]
|
||||||
|
|
||||||
|
if settings.smtp_host and not settings.smtp_from_email:
|
||||||
|
raise HTTPException(400, "from email required when SMTP is configured")
|
||||||
|
db.add(settings)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/settings/smtp/test")
|
||||||
|
def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
|
require_admin(req, db)
|
||||||
|
settings = get_settings(db)
|
||||||
|
recipient = (data.get("recipient") or "").strip()
|
||||||
|
if not recipient:
|
||||||
|
raise HTTPException(400, "recipient required")
|
||||||
|
try:
|
||||||
|
send_html_email(
|
||||||
|
settings,
|
||||||
|
recipient,
|
||||||
|
"SMTP-Test – Cluedo HP",
|
||||||
|
"<div style='font-family:Georgia,serif;padding:24px;background:#17161b;color:#f5efdc'><h2 style='color:#e9d8a6'>SMTP funktioniert</h2><p>Der Einladungsversand ist bereit.</p></div>",
|
||||||
|
"SMTP funktioniert. Der Einladungsversand ist bereit.",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(502, f"SMTP test failed: {exc}")
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import InviteToken, User
|
||||||
|
from ..security import hash_password, make_session_value, set_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth/invite", tags=["invites"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_invite(token: str, db: Session):
|
||||||
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first()
|
||||||
|
expires_at = invite.expires_at if invite else None
|
||||||
|
if expires_at and expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(410, "invite is invalid or expired")
|
||||||
|
user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(410, "invite is invalid or expired")
|
||||||
|
return invite, user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{token}")
|
||||||
|
def invite_info(token: str, db: Session = Depends(get_db)):
|
||||||
|
_invite, user = get_invite(token, db)
|
||||||
|
return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{token}")
|
||||||
|
def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)):
|
||||||
|
invite, user = get_invite(token, db)
|
||||||
|
password = data.get("password") or ""
|
||||||
|
if len(password) < 8:
|
||||||
|
raise HTTPException(400, "password too short (min 8)")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
invite.used_at = datetime.now(timezone.utc)
|
||||||
|
db.add(user)
|
||||||
|
db.add(invite)
|
||||||
|
db.commit()
|
||||||
|
set_session(resp, make_session_value(user.id))
|
||||||
|
return {"ok": True}
|
||||||
@@ -25,6 +25,8 @@ import WinnerCard from "./components/WinnerCard";
|
|||||||
import WinnerBadge from "./components/WinnerBadge";
|
import WinnerBadge from "./components/WinnerBadge";
|
||||||
import NewGameModal from "./components/NewGameModal";
|
import NewGameModal from "./components/NewGameModal";
|
||||||
import StatsModal from "./components/StatsModal";
|
import StatsModal from "./components/StatsModal";
|
||||||
|
import AdminSettingsModal from "./components/AdminSettingsModal";
|
||||||
|
import InvitePage from "./components/InvitePage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useHpGlobalStyles();
|
useHpGlobalStyles();
|
||||||
@@ -62,6 +64,7 @@ export default function App() {
|
|||||||
const [chipEntry, setChipEntry] = useState(null);
|
const [chipEntry, setChipEntry] = useState(null);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const [adminOpen, setAdminOpen] = useState(false);
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const [pwOpen, setPwOpen] = useState(false);
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
const [pw1, setPw1] = useState("");
|
const [pw1, setPw1] = useState("");
|
||||||
@@ -94,6 +97,7 @@ export default function App() {
|
|||||||
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
const [celebrateOpen, setCelebrateOpen] = useState(false);
|
||||||
const [celebrateName, setCelebrateName] = useState("");
|
const [celebrateName, setCelebrateName] = useState("");
|
||||||
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
|
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
|
// baseline per game: beim ersten Meta-Load NICHT feiern
|
||||||
const winnerBaselineRef = useRef(false);
|
const winnerBaselineRef = useRef(false);
|
||||||
@@ -628,6 +632,10 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ===== Login page =====
|
// ===== Login page =====
|
||||||
|
if (inviteToken) {
|
||||||
|
return <InvitePage token={inviteToken} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
return (
|
return (
|
||||||
<LoginPage
|
<LoginPage
|
||||||
@@ -694,6 +702,10 @@ export default function App() {
|
|||||||
setAdminOpen(true);
|
setAdminOpen(true);
|
||||||
setUserMenuOpen(false);
|
setUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
openAdminSettings={() => {
|
||||||
|
setAdminSettingsOpen(true);
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}}
|
||||||
doLogout={doLogout}
|
doLogout={doLogout}
|
||||||
onOpenNewGame={() => setNewGameOpen(true)}
|
onOpenNewGame={() => setNewGameOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -701,6 +713,9 @@ export default function App() {
|
|||||||
{me.role === "admin" && (
|
{me.role === "admin" && (
|
||||||
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
|
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
|
||||||
)}
|
)}
|
||||||
|
{me.role === "admin" && (
|
||||||
|
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<GamePickerCard
|
<GamePickerCard
|
||||||
games={games}
|
games={games}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +39,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setForm({ ...emptyForm });
|
setForm({ ...emptyForm });
|
||||||
setMsg("");
|
setMsg("");
|
||||||
|
setNotice("");
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
disabled: !!user.disabled,
|
disabled: !!user.disabled,
|
||||||
});
|
});
|
||||||
setMsg("");
|
setMsg("");
|
||||||
|
setNotice("");
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
const saveUser = async () => {
|
const saveUser = async () => {
|
||||||
setMsg("");
|
setMsg("");
|
||||||
if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich.");
|
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.");
|
if (editingUser && form.password && form.password.length < 8) return setMsg("❌ Neues Passwort muss mindestens 8 Zeichen haben.");
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -76,11 +79,12 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
};
|
};
|
||||||
if (form.password) payload.password = form.password;
|
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",
|
method: editingUser ? "PATCH" : "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
|
setNotice(result.invite_sent ? "✅ Einladung wurde per E-Mail versendet." : "✅ User gespeichert.");
|
||||||
closeEditor();
|
closeEditor();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen."));
|
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>
|
<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>
|
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
|
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
|
||||||
|
|
||||||
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
|
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
@@ -150,10 +155,11 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
|
|||||||
<div style={{ marginTop: 18, display: "grid", gap: 10 }}>
|
<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}>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}>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>
|
<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>}
|
{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>}
|
{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>
|
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? "Speichern …" : editingUser ? "Änderungen speichern" : "User erstellen"}</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span style={styles.helpMiniTag}>🛡️</span>
|
||||||
<div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div>
|
<div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div style={{ ...styles.helpText, marginTop: 16 }}>
|
<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,
|
openDesignModal,
|
||||||
openStatsModal,
|
openStatsModal,
|
||||||
openAdminPanel,
|
openAdminPanel,
|
||||||
|
openAdminSettings,
|
||||||
doLogout,
|
doLogout,
|
||||||
onOpenNewGame,
|
onOpenNewGame,
|
||||||
}) {
|
}) {
|
||||||
@@ -75,6 +76,15 @@ export default function TopBar({
|
|||||||
>
|
>
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
</button>
|
</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 { 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-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
|
||||||
.hp-admin-editor-card { overflow: hidden; }
|
.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-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 { color: #baf3c9 !important; font-size: 13px; }
|
||||||
.hp-admin-status--disabled { color: #ffb3b3 !important; }
|
.hp-admin-status--disabled { color: #ffb3b3 !important; }
|
||||||
@media (max-width: 600px) {
|
@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-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 { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
|
||||||
.hp-topbar > div:first-child { min-width: 0; flex: 1; }
|
.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-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 { 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-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-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 { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
|
||||||
.hp-row button { min-height: 40px; }
|
.hp-row button { min-height: 40px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user