Add invite email templates and delivery workflow
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
BUILD_NUMBER = "2802262051"
|
||||
BUILD_NUMBER = "0103261543"
|
||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ def init_db() -> None:
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
expires_at TEXT,
|
||||
recipient_email TEXT,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -362,6 +363,10 @@ def init_db() -> None:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -1063,9 +1068,10 @@ def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
|
||||
"use_count": use_count,
|
||||
"enabled": bool(row[8]),
|
||||
"expires_at": expires_at,
|
||||
"created_by": row[10],
|
||||
"created_at": row[11],
|
||||
"updated_at": row[12],
|
||||
"recipient_email": row[10],
|
||||
"created_by": row[11],
|
||||
"created_at": row[12],
|
||||
"updated_at": row[13],
|
||||
"is_expired": is_expired,
|
||||
"remaining_uses": remaining_uses,
|
||||
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
|
||||
@@ -1077,7 +1083,7 @@ def list_signup_invites() -> list[Dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
|
||||
expires_at, created_by, created_at, updated_at
|
||||
expires_at, recipient_email, created_by, created_at, updated_at
|
||||
FROM signup_invites
|
||||
ORDER BY created_at DESC, id DESC
|
||||
"""
|
||||
@@ -1090,7 +1096,7 @@ def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
|
||||
expires_at, created_by, created_at, updated_at
|
||||
expires_at, recipient_email, created_by, created_at, updated_at
|
||||
FROM signup_invites
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -1106,7 +1112,7 @@ def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
|
||||
expires_at, created_by, created_at, updated_at
|
||||
expires_at, recipient_email, created_by, created_at, updated_at
|
||||
FROM signup_invites
|
||||
WHERE code = ? COLLATE NOCASE
|
||||
""",
|
||||
@@ -1127,6 +1133,7 @@ def create_signup_invite(
|
||||
max_uses: Optional[int] = None,
|
||||
enabled: bool = True,
|
||||
expires_at: Optional[str] = None,
|
||||
recipient_email: Optional[str] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
@@ -1135,9 +1142,9 @@ def create_signup_invite(
|
||||
"""
|
||||
INSERT INTO signup_invites (
|
||||
code, label, description, profile_id, role, max_uses, use_count, enabled,
|
||||
expires_at, created_by, created_at, updated_at
|
||||
expires_at, recipient_email, created_by, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
code,
|
||||
@@ -1148,6 +1155,7 @@ def create_signup_invite(
|
||||
max_uses,
|
||||
1 if enabled else 0,
|
||||
expires_at,
|
||||
recipient_email,
|
||||
created_by,
|
||||
timestamp,
|
||||
timestamp,
|
||||
@@ -1171,6 +1179,7 @@ def update_signup_invite(
|
||||
max_uses: Optional[int],
|
||||
enabled: bool,
|
||||
expires_at: Optional[str],
|
||||
recipient_email: Optional[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
with _connect() as conn:
|
||||
@@ -1178,7 +1187,7 @@ def update_signup_invite(
|
||||
"""
|
||||
UPDATE signup_invites
|
||||
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
|
||||
enabled = ?, expires_at = ?, updated_at = ?
|
||||
enabled = ?, expires_at = ?, recipient_email = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
@@ -1190,6 +1199,7 @@ def update_signup_invite(
|
||||
max_uses,
|
||||
1 if enabled else 0,
|
||||
expires_at,
|
||||
recipient_email,
|
||||
timestamp,
|
||||
invite_id,
|
||||
),
|
||||
|
||||
@@ -79,6 +79,14 @@ from ..services.user_cache import (
|
||||
save_jellyseerr_users_cache,
|
||||
clear_user_import_caches,
|
||||
)
|
||||
from ..services.invite_email import (
|
||||
TEMPLATE_KEYS as INVITE_EMAIL_TEMPLATE_KEYS,
|
||||
get_invite_email_templates,
|
||||
reset_invite_email_template,
|
||||
save_invite_email_template,
|
||||
send_templated_email,
|
||||
smtp_email_config_ready,
|
||||
)
|
||||
import logging
|
||||
from ..logging_config import configure_logging
|
||||
from ..routers import requests as requests_router
|
||||
@@ -240,10 +248,20 @@ def _user_inviter_details(user: Optional[Dict[str, Any]]) -> Optional[Dict[str,
|
||||
"created_at": invite.get("created_at"),
|
||||
"enabled": invite.get("enabled"),
|
||||
"is_usable": invite.get("is_usable"),
|
||||
"recipient_email": invite.get("recipient_email"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_user_invite(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not user:
|
||||
return None
|
||||
invite_code = user.get("invited_by_code")
|
||||
if not isinstance(invite_code, str) or not invite_code.strip():
|
||||
return None
|
||||
return get_signup_invite_by_code(invite_code.strip())
|
||||
|
||||
|
||||
def _build_invite_trace_payload() -> Dict[str, Any]:
|
||||
users = get_all_users()
|
||||
invites = list_signup_invites()
|
||||
@@ -1077,6 +1095,7 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
||||
"jellyfin": {"status": "skipped", "detail": "Jellyfin not configured"},
|
||||
"jellyseerr": {"status": "skipped", "detail": "Seerr not configured or no linked user ID"},
|
||||
"invites": {"status": "pending", "disabled": 0},
|
||||
"email": {"status": "skipped", "detail": "No email action required"},
|
||||
}
|
||||
|
||||
if action == "ban":
|
||||
@@ -1093,6 +1112,19 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
||||
else:
|
||||
result["invites"] = {"status": "ok", "disabled": 0}
|
||||
|
||||
if action in {"ban", "remove"}:
|
||||
try:
|
||||
invite = _resolve_user_invite(user)
|
||||
email_result = await send_templated_email(
|
||||
"banned",
|
||||
invite=invite,
|
||||
user=user,
|
||||
reason="Account banned" if action == "ban" else "Account removed",
|
||||
)
|
||||
result["email"] = {"status": "ok", **email_result}
|
||||
except Exception as exc:
|
||||
result["email"] = {"status": "error", "detail": str(exc)}
|
||||
|
||||
if jellyfin.configured():
|
||||
try:
|
||||
jellyfin_user = await jellyfin.find_user_by_name(username)
|
||||
@@ -1138,7 +1170,7 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
|
||||
|
||||
if any(
|
||||
isinstance(system, dict) and system.get("status") == "error"
|
||||
for system in (result.get("jellyfin"), result.get("jellyseerr"))
|
||||
for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
|
||||
):
|
||||
result["status"] = "partial"
|
||||
return result
|
||||
@@ -1549,6 +1581,99 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invites/email/templates")
|
||||
async def get_invite_email_template_settings() -> Dict[str, Any]:
|
||||
ready, detail = smtp_email_config_ready()
|
||||
return {
|
||||
"status": "ok",
|
||||
"email": {
|
||||
"configured": ready,
|
||||
"detail": detail,
|
||||
},
|
||||
"templates": list(get_invite_email_templates().values()),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/invites/email/templates/{template_key}")
|
||||
async def update_invite_email_template_settings(template_key: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Email template not found")
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
subject = _normalize_optional_text(payload.get("subject"))
|
||||
body_text = _normalize_optional_text(payload.get("body_text"))
|
||||
body_html = _normalize_optional_text(payload.get("body_html"))
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="subject is required")
|
||||
if not body_text and not body_html:
|
||||
raise HTTPException(status_code=400, detail="At least one email body is required")
|
||||
template = save_invite_email_template(
|
||||
template_key,
|
||||
subject=subject,
|
||||
body_text=body_text or "",
|
||||
body_html=body_html or "",
|
||||
)
|
||||
return {"status": "ok", "template": template}
|
||||
|
||||
|
||||
@router.delete("/invites/email/templates/{template_key}")
|
||||
async def reset_invite_email_template_settings(template_key: str) -> Dict[str, Any]:
|
||||
if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Email template not found")
|
||||
template = reset_invite_email_template(template_key)
|
||||
return {"status": "ok", "template": template}
|
||||
|
||||
|
||||
@router.post("/invites/email/send")
|
||||
async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||
template_key = str(payload.get("template_key") or "").strip().lower()
|
||||
if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
|
||||
raise HTTPException(status_code=400, detail="template_key is invalid")
|
||||
|
||||
invite: Optional[Dict[str, Any]] = None
|
||||
invite_id = payload.get("invite_id")
|
||||
if invite_id not in (None, ""):
|
||||
try:
|
||||
invite = get_signup_invite_by_id(int(invite_id))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="invite_id must be a number") from exc
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
|
||||
user: Optional[Dict[str, Any]] = None
|
||||
username = _normalize_optional_text(payload.get("username"))
|
||||
if username:
|
||||
user = get_user_by_username(username)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if invite is None:
|
||||
invite = _resolve_user_invite(user)
|
||||
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
message = _normalize_optional_text(payload.get("message"))
|
||||
reason = _normalize_optional_text(payload.get("reason"))
|
||||
|
||||
try:
|
||||
result = await send_templated_email(
|
||||
template_key,
|
||||
invite=invite,
|
||||
user=user,
|
||||
recipient_email=recipient_email,
|
||||
message=message,
|
||||
reason=reason,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"template_key": template_key,
|
||||
**result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invites/trace")
|
||||
async def get_invite_trace() -> Dict[str, Any]:
|
||||
return {"status": "ok", "trace": _build_invite_trace_payload()}
|
||||
@@ -1569,6 +1694,9 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = create_signup_invite(
|
||||
code=code,
|
||||
@@ -1579,11 +1707,35 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
recipient_email=recipient_email,
|
||||
created_by=current_user.get("username"),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
|
||||
return {"status": "ok", "invite": invite}
|
||||
email_result = None
|
||||
email_error = None
|
||||
if send_email:
|
||||
try:
|
||||
email_result = await send_templated_email(
|
||||
"invited",
|
||||
invite=invite,
|
||||
user=current_user,
|
||||
recipient_email=recipient_email,
|
||||
message=delivery_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
return {
|
||||
"status": "partial" if email_error else "ok",
|
||||
"invite": invite,
|
||||
"email": (
|
||||
{"status": "ok", **email_result}
|
||||
if email_result
|
||||
else {"status": "error", "detail": email_error}
|
||||
if email_error
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/invites/{invite_id}")
|
||||
@@ -1601,6 +1753,9 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
role = _normalize_role_or_none(payload.get("role"))
|
||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||
try:
|
||||
invite = update_signup_invite(
|
||||
invite_id,
|
||||
@@ -1612,12 +1767,35 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
recipient_email=recipient_email,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
return {"status": "ok", "invite": invite}
|
||||
email_result = None
|
||||
email_error = None
|
||||
if send_email:
|
||||
try:
|
||||
email_result = await send_templated_email(
|
||||
"invited",
|
||||
invite=invite,
|
||||
recipient_email=recipient_email,
|
||||
message=delivery_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
return {
|
||||
"status": "partial" if email_error else "ok",
|
||||
"invite": invite,
|
||||
"email": (
|
||||
{"status": "ok", **email_result}
|
||||
if email_result
|
||||
else {"status": "error", "detail": email_error}
|
||||
if email_error
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/invites/{invite_id}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import defaultdict, deque
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
@@ -48,8 +49,10 @@ from ..services.user_cache import (
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
)
|
||||
from ..services.invite_email import send_templated_email
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
STREAM_TOKEN_TTL_SECONDS = 120
|
||||
|
||||
@@ -356,6 +359,7 @@ def _serialize_self_invite(invite: dict) -> dict:
|
||||
"remaining_uses": invite.get("remaining_uses"),
|
||||
"enabled": bool(invite.get("enabled")),
|
||||
"expires_at": invite.get("expires_at"),
|
||||
"recipient_email": invite.get("recipient_email"),
|
||||
"is_expired": bool(invite.get("is_expired")),
|
||||
"is_usable": bool(invite.get("is_usable")),
|
||||
"created_at": invite.get("created_at"),
|
||||
@@ -427,6 +431,7 @@ def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
||||
"label": invite.get("label"),
|
||||
"description": invite.get("description"),
|
||||
"profile_id": invite.get("profile_id"),
|
||||
"recipient_email": invite.get("recipient_email"),
|
||||
"profile": (
|
||||
{"id": profile.get("id"), "name": profile.get("name")}
|
||||
if isinstance(profile, dict)
|
||||
@@ -770,6 +775,16 @@ async def signup(payload: dict) -> dict:
|
||||
):
|
||||
set_user_jellyseerr_id(username, matched_jellyseerr_user_id)
|
||||
created_user = get_user_by_username(username)
|
||||
if created_user:
|
||||
try:
|
||||
await send_templated_email(
|
||||
"welcome",
|
||||
invite=invite,
|
||||
user=created_user,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Welcome email delivery is best-effort and must not break signup.
|
||||
logger.warning("Welcome email send skipped for %s: %s", username, exc)
|
||||
_assert_user_can_login(created_user)
|
||||
token = create_access_token(username, role)
|
||||
set_last_login(username)
|
||||
@@ -858,10 +873,15 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
|
||||
label = payload.get("label")
|
||||
description = payload.get("description")
|
||||
recipient_email = payload.get("recipient_email")
|
||||
if label is not None:
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
if recipient_email is not None:
|
||||
recipient_email = str(recipient_email).strip() or None
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
if master_invite:
|
||||
@@ -892,9 +912,34 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
recipient_email=recipient_email,
|
||||
created_by=username,
|
||||
)
|
||||
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||
email_result = None
|
||||
email_error = None
|
||||
if send_email:
|
||||
try:
|
||||
email_result = await send_templated_email(
|
||||
"invited",
|
||||
invite=invite,
|
||||
user=current_user,
|
||||
recipient_email=recipient_email,
|
||||
message=delivery_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
status_value = "partial" if email_error else "ok"
|
||||
return {
|
||||
"status": status_value,
|
||||
"invite": _serialize_self_invite(invite),
|
||||
"email": (
|
||||
{"status": "ok", **email_result}
|
||||
if email_result
|
||||
else {"status": "error", "detail": email_error}
|
||||
if email_error
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/profile/invites/{invite_id}")
|
||||
@@ -919,10 +964,15 @@ async def update_profile_invite(
|
||||
|
||||
label = payload.get("label", existing.get("label"))
|
||||
description = payload.get("description", existing.get("description"))
|
||||
recipient_email = payload.get("recipient_email", existing.get("recipient_email"))
|
||||
if label is not None:
|
||||
label = str(label).strip() or None
|
||||
if description is not None:
|
||||
description = str(description).strip() or None
|
||||
if recipient_email is not None:
|
||||
recipient_email = str(recipient_email).strip() or None
|
||||
send_email = bool(payload.get("send_email"))
|
||||
delivery_message = str(payload.get("message") or "").strip() or None
|
||||
|
||||
master_invite = _get_self_service_master_invite()
|
||||
if master_invite:
|
||||
@@ -948,10 +998,35 @@ async def update_profile_invite(
|
||||
max_uses=max_uses,
|
||||
enabled=enabled,
|
||||
expires_at=expires_at,
|
||||
recipient_email=recipient_email,
|
||||
)
|
||||
if not invite:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
return {"status": "ok", "invite": _serialize_self_invite(invite)}
|
||||
email_result = None
|
||||
email_error = None
|
||||
if send_email:
|
||||
try:
|
||||
email_result = await send_templated_email(
|
||||
"invited",
|
||||
invite=invite,
|
||||
user=current_user,
|
||||
recipient_email=recipient_email,
|
||||
message=delivery_message,
|
||||
)
|
||||
except Exception as exc:
|
||||
email_error = str(exc)
|
||||
status_value = "partial" if email_error else "ok"
|
||||
return {
|
||||
"status": status_value,
|
||||
"invite": _serialize_self_invite(invite),
|
||||
"email": (
|
||||
{"status": "ok", **email_result}
|
||||
if email_result
|
||||
else {"status": "error", "detail": email_error}
|
||||
if email_error
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/profile/invites/{invite_id}")
|
||||
|
||||
463
backend/app/services/invite_email.py
Normal file
463
backend/app/services/invite_email.py
Normal file
@@ -0,0 +1,463 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ..build_info import BUILD_NUMBER
|
||||
from ..config import settings as env_settings
|
||||
from ..db import delete_setting, get_setting, set_setting
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEMPLATE_SETTING_PREFIX = "invite_email_template_"
|
||||
TEMPLATE_KEYS = ("invited", "welcome", "warning", "banned")
|
||||
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
||||
|
||||
TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
|
||||
"invited": {
|
||||
"label": "You have been invited",
|
||||
"description": "Sent when an invite link is created and emailed to a recipient.",
|
||||
},
|
||||
"welcome": {
|
||||
"label": "Welcome / How it works",
|
||||
"description": "Sent after an invited user completes signup.",
|
||||
},
|
||||
"warning": {
|
||||
"label": "Warning",
|
||||
"description": "Manual warning template for account or behavior notices.",
|
||||
},
|
||||
"banned": {
|
||||
"label": "Banned",
|
||||
"description": "Sent when an account is banned or removed.",
|
||||
},
|
||||
}
|
||||
|
||||
TEMPLATE_PLACEHOLDERS = [
|
||||
"app_name",
|
||||
"app_url",
|
||||
"build_number",
|
||||
"how_it_works_url",
|
||||
"invite_code",
|
||||
"invite_description",
|
||||
"invite_expires_at",
|
||||
"invite_label",
|
||||
"invite_link",
|
||||
"invite_remaining_uses",
|
||||
"inviter_username",
|
||||
"message",
|
||||
"reason",
|
||||
"recipient_email",
|
||||
"role",
|
||||
"username",
|
||||
]
|
||||
|
||||
DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
||||
"invited": {
|
||||
"subject": "{{app_name}} invite for {{recipient_email}}",
|
||||
"body_text": (
|
||||
"You have been invited to {{app_name}}.\n\n"
|
||||
"Invite code: {{invite_code}}\n"
|
||||
"Signup link: {{invite_link}}\n"
|
||||
"Invited by: {{inviter_username}}\n"
|
||||
"Invite label: {{invite_label}}\n"
|
||||
"Expires: {{invite_expires_at}}\n"
|
||||
"Remaining uses: {{invite_remaining_uses}}\n\n"
|
||||
"{{invite_description}}\n\n"
|
||||
"{{message}}\n\n"
|
||||
"How it works: {{how_it_works_url}}\n"
|
||||
"Build: {{build_number}}\n"
|
||||
),
|
||||
"body_html": (
|
||||
"<h1>You have been invited</h1>"
|
||||
"<p>You have been invited to <strong>{{app_name}}</strong>.</p>"
|
||||
"<p><strong>Invite code:</strong> {{invite_code}}<br />"
|
||||
"<strong>Invited by:</strong> {{inviter_username}}<br />"
|
||||
"<strong>Invite label:</strong> {{invite_label}}<br />"
|
||||
"<strong>Expires:</strong> {{invite_expires_at}}<br />"
|
||||
"<strong>Remaining uses:</strong> {{invite_remaining_uses}}</p>"
|
||||
"<p>{{invite_description}}</p>"
|
||||
"<p>{{message}}</p>"
|
||||
"<p><a href=\"{{invite_link}}\">Accept invite and create account</a></p>"
|
||||
"<p><a href=\"{{how_it_works_url}}\">How it works</a></p>"
|
||||
"<p class=\"meta\">Build {{build_number}}</p>"
|
||||
),
|
||||
},
|
||||
"welcome": {
|
||||
"subject": "Welcome to {{app_name}}",
|
||||
"body_text": (
|
||||
"Welcome to {{app_name}}, {{username}}.\n\n"
|
||||
"Your account is ready.\n"
|
||||
"Open: {{app_url}}\n"
|
||||
"How it works: {{how_it_works_url}}\n"
|
||||
"Role: {{role}}\n\n"
|
||||
"{{message}}\n"
|
||||
),
|
||||
"body_html": (
|
||||
"<h1>Welcome</h1>"
|
||||
"<p>Your {{app_name}} account is ready, <strong>{{username}}</strong>.</p>"
|
||||
"<p><strong>Role:</strong> {{role}}</p>"
|
||||
"<p><a href=\"{{app_url}}\">Open {{app_name}}</a><br />"
|
||||
"<a href=\"{{how_it_works_url}}\">Read how it works</a></p>"
|
||||
"<p>{{message}}</p>"
|
||||
),
|
||||
},
|
||||
"warning": {
|
||||
"subject": "{{app_name}} account warning",
|
||||
"body_text": (
|
||||
"Hello {{username}},\n\n"
|
||||
"This is a warning regarding your {{app_name}} account.\n\n"
|
||||
"Reason: {{reason}}\n\n"
|
||||
"{{message}}\n\n"
|
||||
"If you need help, contact the admin.\n"
|
||||
),
|
||||
"body_html": (
|
||||
"<h1>Account warning</h1>"
|
||||
"<p>Hello <strong>{{username}}</strong>,</p>"
|
||||
"<p>This is a warning regarding your {{app_name}} account.</p>"
|
||||
"<p><strong>Reason:</strong> {{reason}}</p>"
|
||||
"<p>{{message}}</p>"
|
||||
"<p>If you need help, contact the admin.</p>"
|
||||
),
|
||||
},
|
||||
"banned": {
|
||||
"subject": "{{app_name}} account status changed",
|
||||
"body_text": (
|
||||
"Hello {{username}},\n\n"
|
||||
"Your {{app_name}} account has been banned or removed.\n\n"
|
||||
"Reason: {{reason}}\n\n"
|
||||
"{{message}}\n"
|
||||
),
|
||||
"body_html": (
|
||||
"<h1>Account status changed</h1>"
|
||||
"<p>Hello <strong>{{username}}</strong>,</p>"
|
||||
"<p>Your {{app_name}} account has been banned or removed.</p>"
|
||||
"<p><strong>Reason:</strong> {{reason}}</p>"
|
||||
"<p>{{message}}</p>"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _template_setting_key(template_key: str) -> str:
|
||||
return f"{TEMPLATE_SETTING_PREFIX}{template_key}"
|
||||
|
||||
|
||||
def _is_valid_email(value: object) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return bool(EMAIL_PATTERN.match(candidate))
|
||||
|
||||
|
||||
def _normalize_email(value: object) -> Optional[str]:
|
||||
if not _is_valid_email(value):
|
||||
return None
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _normalize_display_text(value: object, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
trimmed = value.strip()
|
||||
return trimmed if trimmed else fallback
|
||||
return str(value)
|
||||
|
||||
|
||||
def _template_context_value(value: object, fallback: str = "") -> str:
|
||||
if value is None:
|
||||
return fallback
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _safe_template_context(context: Dict[str, object]) -> Dict[str, str]:
|
||||
safe: Dict[str, str] = {}
|
||||
for key in TEMPLATE_PLACEHOLDERS:
|
||||
safe[key] = _template_context_value(context.get(key), "")
|
||||
return safe
|
||||
|
||||
|
||||
def _render_template_string(template: str, context: Dict[str, str], *, escape_html: bool = False) -> str:
|
||||
if not isinstance(template, str):
|
||||
return ""
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
value = context.get(key, "")
|
||||
return html.escape(value) if escape_html else value
|
||||
|
||||
return PLACEHOLDER_PATTERN.sub(_replace, template)
|
||||
|
||||
|
||||
def _strip_html_for_text(value: str) -> str:
|
||||
text = re.sub(r"<br\s*/?>", "\n", value, flags=re.IGNORECASE)
|
||||
text = re.sub(r"</p>", "\n\n", text, flags=re.IGNORECASE)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return html.unescape(text).strip()
|
||||
|
||||
|
||||
def _build_default_base_url() -> str:
|
||||
runtime = get_runtime_settings()
|
||||
for candidate in (
|
||||
runtime.magent_application_url,
|
||||
runtime.magent_proxy_base_url,
|
||||
env_settings.cors_allow_origin,
|
||||
):
|
||||
normalized = _normalize_display_text(candidate)
|
||||
if normalized:
|
||||
return normalized.rstrip("/")
|
||||
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
||||
return f"http://localhost:{port}"
|
||||
|
||||
|
||||
def build_invite_email_context(
|
||||
*,
|
||||
invite: Optional[Dict[str, Any]] = None,
|
||||
user: Optional[Dict[str, Any]] = None,
|
||||
recipient_email: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
overrides: Optional[Dict[str, object]] = None,
|
||||
) -> Dict[str, str]:
|
||||
app_url = _build_default_base_url()
|
||||
invite_code = _normalize_display_text(invite.get("code") if invite else None, "Not set")
|
||||
invite_link = f"{app_url}/signup?code={invite_code}" if invite_code != "Not set" else f"{app_url}/signup"
|
||||
remaining_uses = invite.get("remaining_uses") if invite else None
|
||||
resolved_recipient = _normalize_email(recipient_email)
|
||||
if not resolved_recipient and invite:
|
||||
resolved_recipient = _normalize_email(invite.get("recipient_email"))
|
||||
if not resolved_recipient and user:
|
||||
resolved_recipient = resolve_user_delivery_email(user)
|
||||
|
||||
context: Dict[str, object] = {
|
||||
"app_name": env_settings.app_name,
|
||||
"app_url": app_url,
|
||||
"build_number": BUILD_NUMBER,
|
||||
"how_it_works_url": f"{app_url}/how-it-works",
|
||||
"invite_code": invite_code,
|
||||
"invite_description": _normalize_display_text(invite.get("description") if invite else None, "No extra details."),
|
||||
"invite_expires_at": _normalize_display_text(invite.get("expires_at") if invite else None, "Never"),
|
||||
"invite_label": _normalize_display_text(invite.get("label") if invite else None, "No label"),
|
||||
"invite_link": invite_link,
|
||||
"invite_remaining_uses": (
|
||||
"Unlimited" if remaining_uses in (None, "") else _normalize_display_text(remaining_uses)
|
||||
),
|
||||
"inviter_username": _normalize_display_text(
|
||||
invite.get("created_by") if invite else (user.get("username") if user else None),
|
||||
"Admin",
|
||||
),
|
||||
"message": _normalize_display_text(message, ""),
|
||||
"reason": _normalize_display_text(reason, "Not specified"),
|
||||
"recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
|
||||
"role": _normalize_display_text(user.get("role") if user else None, "user"),
|
||||
"username": _normalize_display_text(user.get("username") if user else None, "there"),
|
||||
}
|
||||
if isinstance(overrides, dict):
|
||||
context.update(overrides)
|
||||
return _safe_template_context(context)
|
||||
|
||||
|
||||
def get_invite_email_templates() -> Dict[str, Dict[str, Any]]:
|
||||
templates: Dict[str, Dict[str, Any]] = {}
|
||||
for template_key in TEMPLATE_KEYS:
|
||||
template = dict(DEFAULT_TEMPLATES[template_key])
|
||||
raw_value = get_setting(_template_setting_key(template_key))
|
||||
if raw_value:
|
||||
try:
|
||||
stored = json.loads(raw_value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
stored = {}
|
||||
if isinstance(stored, dict):
|
||||
for field in ("subject", "body_text", "body_html"):
|
||||
if isinstance(stored.get(field), str):
|
||||
template[field] = stored[field]
|
||||
templates[template_key] = {
|
||||
"key": template_key,
|
||||
"label": TEMPLATE_METADATA[template_key]["label"],
|
||||
"description": TEMPLATE_METADATA[template_key]["description"],
|
||||
"placeholders": TEMPLATE_PLACEHOLDERS,
|
||||
**template,
|
||||
}
|
||||
return templates
|
||||
|
||||
|
||||
def get_invite_email_template(template_key: str) -> Dict[str, Any]:
|
||||
if template_key not in TEMPLATE_KEYS:
|
||||
raise ValueError(f"Unknown email template: {template_key}")
|
||||
return get_invite_email_templates()[template_key]
|
||||
|
||||
|
||||
def save_invite_email_template(
|
||||
template_key: str,
|
||||
*,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
body_html: str,
|
||||
) -> Dict[str, Any]:
|
||||
if template_key not in TEMPLATE_KEYS:
|
||||
raise ValueError(f"Unknown email template: {template_key}")
|
||||
payload = {
|
||||
"subject": subject,
|
||||
"body_text": body_text,
|
||||
"body_html": body_html,
|
||||
}
|
||||
set_setting(_template_setting_key(template_key), json.dumps(payload))
|
||||
return get_invite_email_template(template_key)
|
||||
|
||||
|
||||
def reset_invite_email_template(template_key: str) -> Dict[str, Any]:
|
||||
if template_key not in TEMPLATE_KEYS:
|
||||
raise ValueError(f"Unknown email template: {template_key}")
|
||||
delete_setting(_template_setting_key(template_key))
|
||||
return get_invite_email_template(template_key)
|
||||
|
||||
|
||||
def render_invite_email_template(
|
||||
template_key: str,
|
||||
*,
|
||||
invite: Optional[Dict[str, Any]] = None,
|
||||
user: Optional[Dict[str, Any]] = None,
|
||||
recipient_email: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
overrides: Optional[Dict[str, object]] = None,
|
||||
) -> Dict[str, str]:
|
||||
template = get_invite_email_template(template_key)
|
||||
context = build_invite_email_context(
|
||||
invite=invite,
|
||||
user=user,
|
||||
recipient_email=recipient_email,
|
||||
message=message,
|
||||
reason=reason,
|
||||
overrides=overrides,
|
||||
)
|
||||
body_html = _render_template_string(template["body_html"], context, escape_html=True)
|
||||
body_text = _render_template_string(template["body_text"], context, escape_html=False)
|
||||
if not body_text.strip() and body_html.strip():
|
||||
body_text = _strip_html_for_text(body_html)
|
||||
subject = _render_template_string(template["subject"], context, escape_html=False)
|
||||
return {
|
||||
"subject": subject.strip(),
|
||||
"body_text": body_text.strip(),
|
||||
"body_html": body_html.strip(),
|
||||
}
|
||||
|
||||
|
||||
def resolve_user_delivery_email(user: Optional[Dict[str, Any]], invite: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not isinstance(user, dict):
|
||||
return _normalize_email(invite.get("recipient_email") if isinstance(invite, dict) else None)
|
||||
username_email = _normalize_email(user.get("username"))
|
||||
if username_email:
|
||||
return username_email
|
||||
if isinstance(invite, dict):
|
||||
invite_email = _normalize_email(invite.get("recipient_email"))
|
||||
if invite_email:
|
||||
return invite_email
|
||||
return None
|
||||
|
||||
|
||||
def smtp_email_config_ready() -> tuple[bool, str]:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.magent_notify_enabled:
|
||||
return False, "Notifications are disabled."
|
||||
if not runtime.magent_notify_email_enabled:
|
||||
return False, "Email notifications are disabled."
|
||||
if not _normalize_display_text(runtime.magent_notify_email_smtp_host):
|
||||
return False, "SMTP host is not configured."
|
||||
if not _normalize_email(runtime.magent_notify_email_from_address):
|
||||
return False, "From email address is not configured."
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
|
||||
port = int(runtime.magent_notify_email_smtp_port or 587)
|
||||
username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
|
||||
password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
|
||||
from_address = _normalize_email(runtime.magent_notify_email_from_address)
|
||||
from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name)
|
||||
use_tls = bool(runtime.magent_notify_email_use_tls)
|
||||
use_ssl = bool(runtime.magent_notify_email_use_ssl)
|
||||
if not host or not from_address:
|
||||
raise RuntimeError("SMTP email settings are incomplete.")
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr((from_name, from_address))
|
||||
message["To"] = recipient_email
|
||||
message.set_content(body_text or _strip_html_for_text(body_html))
|
||||
if body_html.strip():
|
||||
message.add_alternative(body_html, subtype="html")
|
||||
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
|
||||
if username and password:
|
||||
smtp.login(username, password)
|
||||
smtp.send_message(message)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(host, port, timeout=20) as smtp:
|
||||
smtp.ehlo()
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if username and password:
|
||||
smtp.login(username, password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
async def send_templated_email(
|
||||
template_key: str,
|
||||
*,
|
||||
invite: Optional[Dict[str, Any]] = None,
|
||||
user: Optional[Dict[str, Any]] = None,
|
||||
recipient_email: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
overrides: Optional[Dict[str, object]] = None,
|
||||
) -> Dict[str, str]:
|
||||
ready, detail = smtp_email_config_ready()
|
||||
if not ready:
|
||||
raise RuntimeError(detail)
|
||||
|
||||
resolved_email = _normalize_email(recipient_email)
|
||||
if not resolved_email:
|
||||
resolved_email = resolve_user_delivery_email(user, invite)
|
||||
if not resolved_email:
|
||||
raise RuntimeError("No valid recipient email is available for this action.")
|
||||
|
||||
rendered = render_invite_email_template(
|
||||
template_key,
|
||||
invite=invite,
|
||||
user=user,
|
||||
recipient_email=resolved_email,
|
||||
message=message,
|
||||
reason=reason,
|
||||
overrides=overrides,
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_send_email_sync,
|
||||
recipient_email=resolved_email,
|
||||
subject=rendered["subject"],
|
||||
body_text=rendered["body_text"],
|
||||
body_html=rendered["body_html"],
|
||||
)
|
||||
logger.info("Email template sent: template=%s recipient=%s", template_key, resolved_email)
|
||||
return {
|
||||
"recipient_email": resolved_email,
|
||||
"subject": rendered["subject"],
|
||||
}
|
||||
@@ -43,6 +43,7 @@ type Invite = {
|
||||
remaining_uses?: number | null
|
||||
enabled: boolean
|
||||
expires_at?: string | null
|
||||
recipient_email?: string | null
|
||||
is_expired?: boolean
|
||||
is_usable?: boolean
|
||||
created_at?: string | null
|
||||
@@ -58,6 +59,9 @@ type InviteForm = {
|
||||
max_uses: string
|
||||
enabled: boolean
|
||||
expires_at: string
|
||||
recipient_email: string
|
||||
send_email: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
type ProfileForm = {
|
||||
@@ -69,10 +73,30 @@ type ProfileForm = {
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
|
||||
type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
|
||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
|
||||
type InviteTraceScope = 'all' | 'invited' | 'direct'
|
||||
type InviteTraceView = 'list' | 'graph'
|
||||
|
||||
type InviteEmailTemplate = {
|
||||
key: InviteEmailTemplateKey
|
||||
label: string
|
||||
description: string
|
||||
placeholders: string[]
|
||||
subject: string
|
||||
body_text: string
|
||||
body_html: string
|
||||
}
|
||||
|
||||
type InviteEmailSendForm = {
|
||||
template_key: InviteEmailTemplateKey
|
||||
recipient_email: string
|
||||
invite_id: string
|
||||
username: string
|
||||
message: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
type InviteTraceRow = {
|
||||
username: string
|
||||
role: string
|
||||
@@ -102,6 +126,18 @@ const defaultInviteForm = (): InviteForm => ({
|
||||
max_uses: '',
|
||||
enabled: true,
|
||||
expires_at: '',
|
||||
recipient_email: '',
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
|
||||
const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({
|
||||
template_key: 'invited',
|
||||
recipient_email: '',
|
||||
invite_id: '',
|
||||
username: '',
|
||||
message: '',
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const defaultProfileForm = (): ProfileForm => ({
|
||||
@@ -137,6 +173,9 @@ export default function AdminInviteManagementPage() {
|
||||
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
|
||||
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
|
||||
const [invitePolicySaving, setInvitePolicySaving] = useState(false)
|
||||
const [templateSaving, setTemplateSaving] = useState(false)
|
||||
const [templateResetting, setTemplateResetting] = useState(false)
|
||||
const [emailSending, setEmailSending] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
@@ -152,6 +191,15 @@ export default function AdminInviteManagementPage() {
|
||||
const [masterInviteSelection, setMasterInviteSelection] = useState('')
|
||||
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
|
||||
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
|
||||
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
|
||||
const [templateForm, setTemplateForm] = useState({
|
||||
subject: '',
|
||||
body_text: '',
|
||||
body_html: '',
|
||||
})
|
||||
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm())
|
||||
const [traceFilter, setTraceFilter] = useState('')
|
||||
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
|
||||
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
|
||||
@@ -161,6 +209,23 @@ export default function AdminInviteManagementPage() {
|
||||
return `${window.location.origin}/signup`
|
||||
}, [])
|
||||
|
||||
const loadTemplateEditor = (
|
||||
templateKey: InviteEmailTemplateKey,
|
||||
templates: InviteEmailTemplate[]
|
||||
) => {
|
||||
const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null
|
||||
if (!template) {
|
||||
setTemplateForm({ subject: '', body_text: '', body_html: '' })
|
||||
return
|
||||
}
|
||||
setSelectedTemplateKey(template.key)
|
||||
setTemplateForm({
|
||||
subject: template.subject ?? '',
|
||||
body_text: template.body_text ?? '',
|
||||
body_html: template.body_html ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleAuthResponse = (response: Response) => {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
@@ -183,11 +248,12 @@ export default function AdminInviteManagementPage() {
|
||||
setError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([
|
||||
const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([
|
||||
authFetch(`${baseUrl}/admin/invites`),
|
||||
authFetch(`${baseUrl}/admin/profiles`),
|
||||
authFetch(`${baseUrl}/admin/users`),
|
||||
authFetch(`${baseUrl}/admin/invites/policy`),
|
||||
authFetch(`${baseUrl}/admin/invites/email/templates`),
|
||||
])
|
||||
if (!inviteRes.ok) {
|
||||
if (handleAuthResponse(inviteRes)) return
|
||||
@@ -205,11 +271,16 @@ export default function AdminInviteManagementPage() {
|
||||
if (handleAuthResponse(policyRes)) return
|
||||
throw new Error(`Failed to load invite policy (${policyRes.status})`)
|
||||
}
|
||||
const [inviteData, profileData, usersData, policyData] = await Promise.all([
|
||||
if (!emailTemplateRes.ok) {
|
||||
if (handleAuthResponse(emailTemplateRes)) return
|
||||
throw new Error(`Failed to load email templates (${emailTemplateRes.status})`)
|
||||
}
|
||||
const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([
|
||||
inviteRes.json(),
|
||||
profileRes.json(),
|
||||
usersRes.json(),
|
||||
policyRes.json(),
|
||||
emailTemplateRes.json(),
|
||||
])
|
||||
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
|
||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||
@@ -219,6 +290,10 @@ export default function AdminInviteManagementPage() {
|
||||
setMasterInviteSelection(
|
||||
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
|
||||
)
|
||||
const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : []
|
||||
setEmailTemplates(nextTemplates)
|
||||
setEmailConfigured(emailTemplateData?.email ?? null)
|
||||
loadTemplateEditor(selectedTemplateKey, nextTemplates)
|
||||
try {
|
||||
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
|
||||
if (jellyfinRes.ok) {
|
||||
@@ -264,6 +339,9 @@ export default function AdminInviteManagementPage() {
|
||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||
enabled: invite.enabled !== false,
|
||||
expires_at: invite.expires_at ?? '',
|
||||
recipient_email: invite.recipient_email ?? '',
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
setStatus(null)
|
||||
setError(null)
|
||||
@@ -285,6 +363,9 @@ export default function AdminInviteManagementPage() {
|
||||
max_uses: inviteForm.max_uses || null,
|
||||
enabled: inviteForm.enabled,
|
||||
expires_at: inviteForm.expires_at || null,
|
||||
recipient_email: inviteForm.recipient_email || null,
|
||||
send_email: inviteForm.send_email,
|
||||
message: inviteForm.message || null,
|
||||
}
|
||||
const url =
|
||||
inviteEditingId == null
|
||||
@@ -300,8 +381,19 @@ export default function AdminInviteManagementPage() {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Save failed')
|
||||
}
|
||||
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
resetInviteEditor()
|
||||
const data = await response.json()
|
||||
if (data?.email?.status === 'ok') {
|
||||
setStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||
)
|
||||
} else if (data?.email?.status === 'error') {
|
||||
setStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||
)
|
||||
} else {
|
||||
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
}
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -349,6 +441,117 @@ export default function AdminInviteManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const prepareInviteEmail = (invite: Invite) => {
|
||||
setEmailSendForm({
|
||||
template_key: 'invited',
|
||||
recipient_email: invite.recipient_email ?? '',
|
||||
invite_id: String(invite.id),
|
||||
username: '',
|
||||
message: '',
|
||||
reason: '',
|
||||
})
|
||||
setActiveTab('emails')
|
||||
setStatus(
|
||||
invite.recipient_email
|
||||
? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.`
|
||||
: `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`
|
||||
)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
|
||||
setSelectedTemplateKey(templateKey)
|
||||
loadTemplateEditor(templateKey, emailTemplates)
|
||||
}
|
||||
|
||||
const saveEmailTemplate = async () => {
|
||||
setTemplateSaving(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(templateForm),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Template save failed')
|
||||
}
|
||||
setStatus(`Saved ${selectedTemplateKey} email template.`)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not save email template.')
|
||||
} finally {
|
||||
setTemplateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetEmailTemplate = async () => {
|
||||
if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return
|
||||
setTemplateResetting(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Template reset failed')
|
||||
}
|
||||
setStatus(`Reset ${selectedTemplateKey} template to default.`)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not reset email template.')
|
||||
} finally {
|
||||
setTemplateResetting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendEmailTemplate = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setEmailSending(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/invites/email/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_key: emailSendForm.template_key,
|
||||
recipient_email: emailSendForm.recipient_email || null,
|
||||
invite_id: emailSendForm.invite_id || null,
|
||||
username: emailSendForm.username || null,
|
||||
message: emailSendForm.message || null,
|
||||
reason: emailSendForm.reason || null,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (handleAuthResponse(response)) return
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Email send failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? 'recipient'}.`)
|
||||
if (emailSendForm.template_key === 'invited') {
|
||||
await loadData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not send email.')
|
||||
} finally {
|
||||
setEmailSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetProfileEditor = () => {
|
||||
setProfileEditingId(null)
|
||||
setProfileForm(defaultProfileForm())
|
||||
@@ -588,8 +791,11 @@ export default function AdminInviteManagementPage() {
|
||||
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
|
||||
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
|
||||
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
|
||||
const invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
|
||||
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
|
||||
const masterInvite = invitePolicy?.master_invite ?? null
|
||||
const selectedTemplate =
|
||||
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
|
||||
|
||||
const inviteTraceRows = useMemo(() => {
|
||||
const inviteByCode = new Map<string, Invite>()
|
||||
@@ -813,6 +1019,20 @@ export default function AdminInviteManagementPage() {
|
||||
<span>users with custom expiry</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Email templates</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{emailTemplates.length}</strong>
|
||||
<span>{invitesWithRecipient} invites with recipient email</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">SMTP email</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
|
||||
<span>{emailConfigured?.detail ?? 'Email settings unavailable'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -866,6 +1086,15 @@ export default function AdminInviteManagementPage() {
|
||||
>
|
||||
Trace map
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'emails'}
|
||||
className={activeTab === 'emails' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('emails')}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
|
||||
@@ -1229,6 +1458,7 @@ export default function AdminInviteManagementPage() {
|
||||
</span>
|
||||
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||||
<span>Created: {formatDate(invite.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1236,6 +1466,9 @@ export default function AdminInviteManagementPage() {
|
||||
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
|
||||
Copy link
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
|
||||
Email invite
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
|
||||
Edit
|
||||
</button>
|
||||
@@ -1371,6 +1604,47 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Delivery</span>
|
||||
<small>Save a recipient email and optionally send the invite immediately.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label>
|
||||
<span>Recipient email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.recipient_email}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
|
||||
}
|
||||
placeholder="person@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Delivery note</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.message}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, message: e.target.value }))
|
||||
}
|
||||
placeholder="Optional message appended to the invite email"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.send_email}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Send “You have been invited” email after saving
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Status</span>
|
||||
@@ -1404,6 +1678,249 @@ export default function AdminInviteManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'emails' && (
|
||||
<div className="invite-admin-stack">
|
||||
<div className="admin-panel invite-admin-list-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Email templates</h2>
|
||||
<p className="lede">
|
||||
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!emailConfigured?.configured && (
|
||||
<div className="status-banner">
|
||||
{emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
|
||||
</div>
|
||||
)}
|
||||
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
|
||||
{emailTemplates.map((template) => (
|
||||
<button
|
||||
key={template.key}
|
||||
type="button"
|
||||
className={selectedTemplateKey === template.key ? 'is-active' : ''}
|
||||
onClick={() => selectEmailTemplate(template.key)}
|
||||
>
|
||||
{template.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedTemplate ? (
|
||||
<div className="invite-email-template-meta">
|
||||
<h3>{selectedTemplate.label}</h3>
|
||||
<p className="lede">{selectedTemplate.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<form
|
||||
className="admin-form compact-form invite-form-layout invite-email-template-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void saveEmailTemplate()
|
||||
}}
|
||||
>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Subject</span>
|
||||
<small>Rendered with the same placeholder variables as the body.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<input
|
||||
value={templateForm.subject}
|
||||
onChange={(event) =>
|
||||
setTemplateForm((current) => ({ ...current, subject: event.target.value }))
|
||||
}
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Plain text body</span>
|
||||
<small>Used for mail clients that prefer text only.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={12}
|
||||
value={templateForm.body_text}
|
||||
onChange={(event) =>
|
||||
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
|
||||
}
|
||||
placeholder="Plain text email body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>HTML body</span>
|
||||
<small>Optional rich HTML version. Basic HTML is supported.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={14}
|
||||
value={templateForm.body_html}
|
||||
onChange={(event) =>
|
||||
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
|
||||
}
|
||||
placeholder="<h1>Hello</h1><p>HTML email body</p>"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Placeholders</span>
|
||||
<small>Use these anywhere in the subject or body.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-email-placeholder-list">
|
||||
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
|
||||
<code key={placeholder}>{`{{${placeholder}}}`}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={templateSaving}>
|
||||
{templateSaving ? 'Saving…' : 'Save template'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void resetEmailTemplate()}
|
||||
disabled={templateResetting}
|
||||
>
|
||||
{templateResetting ? 'Resetting…' : 'Reset to default'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel invite-admin-form-panel">
|
||||
<h2>Send email</h2>
|
||||
<p className="lede">
|
||||
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
|
||||
</p>
|
||||
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Template</span>
|
||||
<small>Select which lifecycle email to send.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Template</span>
|
||||
<select
|
||||
value={emailSendForm.template_key}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({
|
||||
...current,
|
||||
template_key: event.target.value as InviteEmailTemplateKey,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{emailTemplates.map((template) => (
|
||||
<option key={template.key} value={template.key}>
|
||||
{template.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Recipient email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={emailSendForm.recipient_email}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({
|
||||
...current,
|
||||
recipient_email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Optional if invite/user already has one"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Context</span>
|
||||
<small>Link the email to an invite or username to fill placeholders automatically.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Invite</span>
|
||||
<select
|
||||
value={emailSendForm.invite_id}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({
|
||||
...current,
|
||||
invite_id: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{invites.map((invite) => (
|
||||
<option key={invite.id} value={invite.id}>
|
||||
{invite.code}
|
||||
{invite.label ? ` - ${invite.label}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
value={emailSendForm.username}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({
|
||||
...current,
|
||||
username: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Optional user lookup"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Reason / note</span>
|
||||
<small>Used by warning and banned templates, and appended to other emails.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label>
|
||||
<span>Reason</span>
|
||||
<input
|
||||
value={emailSendForm.reason}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
|
||||
}
|
||||
placeholder="Optional reason"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Message</span>
|
||||
<textarea
|
||||
rows={6}
|
||||
value={emailSendForm.message}
|
||||
onChange={(event) =>
|
||||
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
|
||||
}
|
||||
placeholder="Optional message"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
|
||||
{emailSending ? 'Sending…' : 'Send email'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'trace' && (
|
||||
<div className="invite-admin-stack">
|
||||
<div className="admin-panel invite-admin-list-panel">
|
||||
|
||||
@@ -4641,6 +4641,48 @@ button:hover:not(:disabled) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.invite-email-template-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.invite-email-template-picker button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.invite-email-template-picker button.is-active {
|
||||
border-color: rgba(135, 182, 255, 0.4);
|
||||
background: rgba(86, 132, 220, 0.14);
|
||||
color: #eef2f7;
|
||||
}
|
||||
|
||||
.invite-email-template-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.invite-email-template-meta h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invite-email-placeholder-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-email-placeholder-list code {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #d6dde8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.admin-panel > h2 + .lede {
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type OwnedInvite = {
|
||||
code: string
|
||||
label?: string | null
|
||||
description?: string | null
|
||||
recipient_email?: string | null
|
||||
max_uses?: number | null
|
||||
use_count: number
|
||||
remaining_uses?: number | null
|
||||
@@ -87,9 +88,12 @@ type OwnedInviteForm = {
|
||||
code: string
|
||||
label: string
|
||||
description: string
|
||||
recipient_email: string
|
||||
max_uses: string
|
||||
expires_at: string
|
||||
enabled: boolean
|
||||
send_email: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
||||
@@ -98,9 +102,12 @@ const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
recipient_email: '',
|
||||
max_uses: '',
|
||||
expires_at: '',
|
||||
enabled: true,
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
@@ -250,9 +257,12 @@ export default function ProfilePage() {
|
||||
code: invite.code ?? '',
|
||||
label: invite.label ?? '',
|
||||
description: invite.description ?? '',
|
||||
recipient_email: invite.recipient_email ?? '',
|
||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||
expires_at: invite.expires_at ?? '',
|
||||
enabled: invite.enabled !== false,
|
||||
send_email: false,
|
||||
message: '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,9 +302,12 @@ export default function ProfilePage() {
|
||||
code: inviteForm.code || null,
|
||||
label: inviteForm.label || null,
|
||||
description: inviteForm.description || null,
|
||||
recipient_email: inviteForm.recipient_email || null,
|
||||
max_uses: inviteForm.max_uses || null,
|
||||
expires_at: inviteForm.expires_at || null,
|
||||
enabled: inviteForm.enabled,
|
||||
send_email: inviteForm.send_email,
|
||||
message: inviteForm.message || null,
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -307,7 +320,18 @@ export default function ProfilePage() {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Invite save failed')
|
||||
}
|
||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (data?.email?.status === 'ok') {
|
||||
setInviteStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||
)
|
||||
} else if (data?.email?.status === 'error') {
|
||||
setInviteStatus(
|
||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||
)
|
||||
} else {
|
||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||
}
|
||||
resetInviteEditor()
|
||||
await reloadInvites()
|
||||
} catch (err) {
|
||||
@@ -603,6 +627,56 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Delivery</span>
|
||||
<small>Save a recipient email and optionally send the invite immediately.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label>
|
||||
<span>Recipient email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.recipient_email}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
recipient_email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="friend@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Delivery note</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.message}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
message: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Optional note to include in the email"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.send_email}
|
||||
onChange={(event) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
send_email: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Send "You have been invited" email after saving
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Limits</span>
|
||||
@@ -700,6 +774,7 @@ export default function ProfilePage() {
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-meta-row">
|
||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||||
<span>
|
||||
Uses: {invite.use_count}
|
||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magent-frontend",
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
Reference in New Issue
Block a user