Merge pull request #12 from cadtoolbox/copilot/add-advanced-authentication-toggle

Add advanced authentication with email-based login and SMTP notifications
This commit is contained in:
Thomas Rambach
2026-02-08 11:18:28 -05:00
committed by GitHub
15 changed files with 2032 additions and 535 deletions
+386 -2
View File
@@ -8,16 +8,39 @@ from sqlalchemy.orm import selectinload
from backend.app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
authenticate_user_by_email,
create_access_token,
get_current_active_user,
get_password_hash,
get_user_by_email,
get_user_by_username,
)
from backend.app.core.database import get_db
from backend.app.models.group import Group
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
from backend.app.schemas.auth import (
ForgotPasswordRequest,
ForgotPasswordResponse,
GroupBrief,
LoginRequest,
LoginResponse,
ResetPasswordRequest,
ResetPasswordResponse,
SetupRequest,
SetupResponse,
SMTPSettings,
TestSMTPRequest,
TestSMTPResponse,
UserResponse,
)
from backend.app.services.email_service import (
create_password_reset_email,
generate_secure_password,
get_smtp_settings,
save_smtp_settings,
send_email,
)
def _user_to_response(user: User) -> UserResponse:
@@ -25,6 +48,7 @@ def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
@@ -46,6 +70,27 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
return setting.value.lower() == "true"
async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
"""Check if advanced authentication is enabled."""
result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
setting = result.scalar_one_or_none()
if setting is None:
return False
return setting.value.lower() == "true"
async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
"""Set advanced authentication enabled status."""
from sqlalchemy import func
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(Settings).values(key="advanced_auth_enabled", value="true" if enabled else "false")
stmt = stmt.on_conflict_do_update(
index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
)
await db.execute(stmt)
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
"""Set authentication enabled status."""
from sqlalchemy import func
@@ -216,7 +261,10 @@ async def disable_auth(
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Login and get access token."""
"""Login and get access token.
Supports username or email-based login. Username lookup is case-insensitive.
"""
# Check if auth is enabled
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
@@ -225,7 +273,15 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
detail="Authentication is not enabled",
)
# Try username-based authentication first
user = await authenticate_user(db, request.username, request.password)
# If username auth failed and advanced auth is enabled, try email-based authentication
if not user:
advanced_auth = await is_advanced_auth_enabled(db)
if advanced_auth:
user = await authenticate_user_by_email(db, request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -263,3 +319,331 @@ async def get_current_user_info(
async def logout():
"""Logout (client should discard token)."""
return {"message": "Logged out successfully"}
# Advanced Authentication Endpoints
@router.post("/smtp/test", response_model=TestSMTPResponse)
async def test_smtp_connection(
test_request: TestSMTPRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Test SMTP connection with provided settings (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can test SMTP settings",
)
try:
smtp_settings = SMTPSettings(
smtp_host=test_request.smtp_host,
smtp_port=test_request.smtp_port,
smtp_username=test_request.smtp_username,
smtp_password=test_request.smtp_password,
smtp_use_tls=test_request.smtp_use_tls,
smtp_from_email=test_request.smtp_from_email,
)
# Send test email
send_email(
smtp_settings=smtp_settings,
to_email=test_request.test_recipient,
subject="BamBuddy SMTP Test",
body_text="This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!",
body_html="<p>This is a test email from <strong>BamBuddy</strong>.</p><p>If you received this, your SMTP settings are working correctly!</p>",
)
logger.info(f"Test email sent successfully to {test_request.test_recipient}")
return TestSMTPResponse(success=True, message="Test email sent successfully")
except Exception as e:
logger.error(f"Failed to send test email: {e}")
return TestSMTPResponse(success=False, message=f"Failed to send test email: {str(e)}")
@router.get("/smtp", response_model=SMTPSettings | None)
async def get_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get SMTP settings (admin only). Password is not returned."""
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can view SMTP settings",
)
smtp_settings = await get_smtp_settings(db)
if smtp_settings:
# Don't return password in response
smtp_settings.smtp_password = None
return smtp_settings
@router.post("/smtp", response_model=dict)
async def save_smtp_config(
smtp_settings: SMTPSettings,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Save SMTP settings (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can update SMTP settings",
)
try:
await save_smtp_settings(db, smtp_settings)
await db.commit()
logger.info(f"SMTP settings updated by admin user: {user.username}")
return {"message": "SMTP settings saved successfully"}
except Exception as e:
await db.rollback()
logger.error(f"Failed to save SMTP settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save SMTP settings: {str(e)}",
)
@router.post("/advanced-auth/enable", response_model=dict)
async def enable_advanced_auth(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Enable advanced authentication (admin only).
Requires SMTP settings to be configured and tested first.
"""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can enable advanced authentication",
)
# Verify SMTP settings are configured
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SMTP settings must be configured before enabling advanced authentication",
)
try:
await set_advanced_auth_enabled(db, True)
await db.commit()
logger.info(f"Advanced authentication enabled by admin user: {user.username}")
return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
except Exception as e:
await db.rollback()
logger.error(f"Failed to enable advanced authentication: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to enable advanced authentication: {str(e)}",
)
@router.post("/advanced-auth/disable", response_model=dict)
async def disable_advanced_auth(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Disable advanced authentication (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can disable advanced authentication",
)
try:
await set_advanced_auth_enabled(db, False)
await db.commit()
logger.info(f"Advanced authentication disabled by admin user: {user.username}")
return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
except Exception as e:
await db.rollback()
logger.error(f"Failed to disable advanced authentication: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to disable advanced authentication: {str(e)}",
)
@router.get("/advanced-auth/status")
async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
"""Get advanced authentication status."""
advanced_auth_enabled = await is_advanced_auth_enabled(db)
smtp_configured = await get_smtp_settings(db) is not None
return {
"advanced_auth_enabled": advanced_auth_enabled,
"smtp_configured": smtp_configured,
}
@router.post("/forgot-password", response_model=ForgotPasswordResponse)
async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
"""Request password reset via email (advanced auth only)."""
import logging
import os
logger = logging.getLogger(__name__)
# Check if advanced auth is enabled
advanced_auth = await is_advanced_auth_enabled(db)
if not advanced_auth:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Advanced authentication is not enabled",
)
# Get SMTP settings
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email service is not configured",
)
# Find user by email
user = await get_user_by_email(db, request.email)
# Always return success message to prevent email enumeration
# but only send email if user exists
if user and user.is_active:
try:
# Generate new password
new_password = generate_secure_password()
user.password_hash = get_password_hash(new_password)
await db.commit()
# Get login URL from environment or use default
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
# Send password reset email
subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
send_email(smtp_settings, user.email, subject, text_body, html_body)
logger.info(f"Password reset email sent to {user.email}")
except Exception as e:
logger.error(f"Failed to send password reset email: {e}")
# Don't reveal error to user for security
return ForgotPasswordResponse(
message="If the email address is associated with an account, a password reset email has been sent."
)
@router.post("/reset-password", response_model=ResetPasswordResponse)
async def reset_user_password(
request: ResetPasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Reset a user's password and send them an email (admin only, advanced auth only)."""
import logging
import os
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
admin_user = result.scalar_one()
if not admin_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can reset user passwords",
)
# Check if advanced auth is enabled
advanced_auth = await is_advanced_auth_enabled(db)
if not advanced_auth:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Advanced authentication is not enabled",
)
# Get SMTP settings
smtp_settings = await get_smtp_settings(db)
if not smtp_settings:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email service is not configured",
)
# Find user to reset
result = await db.execute(select(User).where(User.id == request.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User does not have an email address configured",
)
try:
# Generate new password
new_password = generate_secure_password()
user.password_hash = get_password_hash(new_password)
await db.commit()
# Get login URL from environment or use default
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
# Send password reset email
subject, text_body, html_body = create_password_reset_email(user.username, new_password, login_url)
send_email(smtp_settings, user.email, subject, text_body, html_body)
logger.info(f"Password reset by admin {admin_user.username} for user {user.username}")
return ResetPasswordResponse(message=f"Password reset email sent to {user.email}")
except Exception as e:
await db.rollback()
logger.error(f"Failed to reset password for user {user.username}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset password: {str(e)}",
)
+90 -6
View File
@@ -15,8 +15,15 @@ from backend.app.models.archive import PrintArchive
from backend.app.models.group import Group
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
from backend.app.services.email_service import (
create_welcome_email,
generate_secure_password,
get_smtp_settings,
send_email,
)
router = APIRouter(prefix="/users", tags=["users"])
@@ -26,6 +33,7 @@ def _user_to_response(user: User) -> UserResponse:
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
@@ -54,9 +62,27 @@ async def create_user(
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
db: AsyncSession = Depends(get_db),
):
"""Create a new user."""
# Check if username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username))
"""Create a new user.
When advanced authentication is enabled:
- Email is required
- Password is auto-generated and emailed to user
- Admin cannot set or see the password
"""
import logging
import os
logger = logging.getLogger(__name__)
# Check if advanced auth is enabled
result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
advanced_auth_setting = result.scalar_one_or_none()
advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
# Check if username already exists (case-insensitive)
existing_user = await db.execute(
select(User).where(func.lower(User.username) == func.lower(user_data.username))
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -70,9 +96,38 @@ async def create_user(
detail="Role must be 'admin' or 'user'",
)
# Advanced auth validation
if advanced_auth_enabled:
if not user_data.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required when advanced authentication is enabled",
)
# Check if email already exists (case-insensitive)
existing_email = await db.execute(
select(User).where(func.lower(User.email) == func.lower(user_data.email))
)
if existing_email.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists",
)
# Generate password if advanced auth enabled, otherwise require password
if advanced_auth_enabled:
password = generate_secure_password()
else:
if not user_data.password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password is required when advanced authentication is disabled",
)
password = user_data.password
new_user = User(
username=user_data.username,
password_hash=get_password_hash(user_data.password),
email=user_data.email,
password_hash=get_password_hash(password),
role=user_data.role,
is_active=True,
)
@@ -92,6 +147,21 @@ async def create_user(
await db.commit()
await db.refresh(new_user)
# Send welcome email if advanced auth enabled
if advanced_auth_enabled and new_user.email:
try:
smtp_settings = await get_smtp_settings(db)
if smtp_settings:
login_url = os.environ.get("APP_URL", "http://localhost:5173") + "/login"
subject, text_body, html_body = create_welcome_email(new_user.username, password, login_url)
send_email(smtp_settings, new_user.email, subject, text_body, html_body)
logger.info(f"Welcome email sent to {new_user.email}")
else:
logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
except Exception as e:
logger.error(f"Failed to send welcome email: {e}")
# Don't fail user creation if email fails
return _user_to_response(new_user)
@@ -161,8 +231,10 @@ async def update_user(
)
if user_data.username is not None:
# Check if new username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
# Check if new username already exists (case-insensitive)
existing_user = await db.execute(
select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
)
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -170,6 +242,18 @@ async def update_user(
)
user.username = user_data.username
if user_data.email is not None:
# Check if new email already exists (case-insensitive)
existing_email = await db.execute(
select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
)
if existing_email.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already exists",
)
user.email = user_data.email
if user_data.password is not None:
user.password_hash = get_password_hash(user_data.password)
+32 -4
View File
@@ -12,7 +12,7 @@ from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt.exceptions import PyJWTError as JWTError
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -128,13 +128,26 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
"""Get a user by username with groups loaded for permission checks."""
result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
"""Get a user by username (case-insensitive) with groups loaded for permission checks."""
result = await db.execute(
select(User).where(func.lower(User.username) == func.lower(username)).options(selectinload(User.groups))
)
return result.scalar_one_or_none()
async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
"""Get a user by email (case-insensitive) with groups loaded for permission checks."""
result = await db.execute(
select(User).where(func.lower(User.email) == func.lower(email)).options(selectinload(User.groups))
)
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
"""Authenticate a user by username and password."""
"""Authenticate a user by username and password.
Username lookup is case-insensitive. Password is case-sensitive.
"""
user = await get_user_by_username(db, username)
if not user:
return None
@@ -145,6 +158,21 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
return user
async def authenticate_user_by_email(db: AsyncSession, email: str, password: str) -> User | None:
"""Authenticate a user by email and password.
Email lookup is case-insensitive. Password is case-sensitive.
"""
user = await get_user_by_email(db, email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if not user.is_active:
return None
return user
async def is_auth_enabled(db: AsyncSession) -> bool:
"""Check if authentication is enabled."""
try:
+1
View File
@@ -24,6 +24,7 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(
String(20), default="user"
+45 -1
View File
@@ -24,7 +24,8 @@ class LoginResponse(BaseModel):
class UserCreate(BaseModel):
username: str
password: str
password: str | None = None # Optional when advanced auth is enabled
email: str | None = None
role: str = "user"
group_ids: list[int] | None = None
@@ -32,6 +33,7 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel):
username: str | None = None
password: str | None = None
email: str | None = None
role: str | None = None
is_active: bool | None = None
group_ids: list[int] | None = None
@@ -40,6 +42,7 @@ class UserUpdate(BaseModel):
class UserResponse(BaseModel):
id: int
username: str
email: str | None = None
role: str # Deprecated, kept for backward compatibility
is_active: bool
is_admin: bool # Computed from role and group membership
@@ -65,3 +68,44 @@ class SetupRequest(BaseModel):
class SetupResponse(BaseModel):
auth_enabled: bool
admin_created: bool | None = None
class ForgotPasswordRequest(BaseModel):
email: str
class ForgotPasswordResponse(BaseModel):
message: str
class ResetPasswordRequest(BaseModel):
user_id: int
class ResetPasswordResponse(BaseModel):
message: str
class SMTPSettings(BaseModel):
smtp_host: str
smtp_port: int
smtp_username: str
smtp_password: str | None = None # Optional for read operations
smtp_use_tls: bool = True
smtp_from_email: str
smtp_from_name: str = "BamBuddy"
class TestSMTPRequest(BaseModel):
smtp_host: str
smtp_port: int
smtp_username: str
smtp_password: str
smtp_use_tls: bool = True
smtp_from_email: str
test_recipient: str
class TestSMTPResponse(BaseModel):
success: bool
message: str
+318
View File
@@ -0,0 +1,318 @@
"""Email service for sending authentication-related emails."""
from __future__ import annotations
import logging
import secrets
import smtplib
import string
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.settings import Settings
from backend.app.schemas.auth import SMTPSettings
logger = logging.getLogger(__name__)
def generate_secure_password(length: int = 16) -> str:
"""Generate a secure random password.
Args:
length: Length of the password (default: 16)
Returns:
A secure random password containing uppercase, lowercase, digits, and special characters
"""
import random
# Define character sets
lowercase = string.ascii_lowercase
uppercase = string.ascii_uppercase
digits = string.digits
special = "!@#$%^&*()_+-=[]{}|;:,.<>?"
# Ensure at least one character from each set
password_chars = [
secrets.choice(lowercase),
secrets.choice(uppercase),
secrets.choice(digits),
secrets.choice(special),
]
# Fill the rest with random characters from all sets
all_chars = lowercase + uppercase + digits + special
password_chars.extend(secrets.choice(all_chars) for _ in range(length - 4))
# Shuffle to avoid predictable patterns
random.shuffle(password_chars)
return "".join(password_chars)
async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None:
"""Get SMTP settings from database.
Args:
db: Database session
Returns:
SMTPSettings object or None if not configured
"""
# Fetch all SMTP-related settings
result = await db.execute(
select(Settings).where(
Settings.key.in_([
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_use_tls",
"smtp_from_email",
"smtp_from_name",
])
)
)
settings_dict = {s.key: s.value for s in result.scalars().all()}
# Check if minimum required settings are present
required_keys = ["smtp_host", "smtp_port", "smtp_username", "smtp_from_email"]
if not all(key in settings_dict for key in required_keys):
return None
return SMTPSettings(
smtp_host=settings_dict["smtp_host"],
smtp_port=int(settings_dict["smtp_port"]),
smtp_username=settings_dict["smtp_username"],
smtp_password=settings_dict.get("smtp_password"),
smtp_use_tls=settings_dict.get("smtp_use_tls", "true").lower() == "true",
smtp_from_email=settings_dict["smtp_from_email"],
smtp_from_name=settings_dict.get("smtp_from_name", "BamBuddy"),
)
async def save_smtp_settings(db: AsyncSession, smtp_settings: SMTPSettings) -> None:
"""Save SMTP settings to database.
Args:
db: Database session
smtp_settings: SMTP settings to save
"""
from sqlalchemy import func
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
settings_data = {
"smtp_host": smtp_settings.smtp_host,
"smtp_port": str(smtp_settings.smtp_port),
"smtp_username": smtp_settings.smtp_username,
"smtp_use_tls": "true" if smtp_settings.smtp_use_tls else "false",
"smtp_from_email": smtp_settings.smtp_from_email,
"smtp_from_name": smtp_settings.smtp_from_name,
}
# Only save password if provided
if smtp_settings.smtp_password:
settings_data["smtp_password"] = smtp_settings.smtp_password
for key, value in settings_data.items():
stmt = sqlite_insert(Settings).values(key=key, value=value)
stmt = stmt.on_conflict_do_update(
index_elements=["key"],
set_={"value": value, "updated_at": func.now()},
)
await db.execute(stmt)
def send_email(
smtp_settings: SMTPSettings,
to_email: str,
subject: str,
body_text: str,
body_html: str | None = None,
) -> None:
"""Send an email using SMTP.
Args:
smtp_settings: SMTP configuration
to_email: Recipient email address
subject: Email subject
body_text: Plain text body
body_html: Optional HTML body
Raises:
Exception: If email sending fails
"""
msg = MIMEMultipart("alternative")
msg["From"] = f"{smtp_settings.smtp_from_name} <{smtp_settings.smtp_from_email}>"
msg["To"] = to_email
msg["Subject"] = subject
# Attach plain text part
msg.attach(MIMEText(body_text, "plain"))
# Attach HTML part if provided
if body_html:
msg.attach(MIMEText(body_html, "html"))
# Send email
try:
if smtp_settings.smtp_use_tls:
# Use TLS (port 587 typically)
with smtplib.SMTP(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
server.starttls()
if smtp_settings.smtp_password:
server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
server.send_message(msg)
else:
# Use SSL (port 465 typically) or no encryption
with smtplib.SMTP_SSL(smtp_settings.smtp_host, smtp_settings.smtp_port, timeout=10) as server:
if smtp_settings.smtp_password:
server.login(smtp_settings.smtp_username, smtp_settings.smtp_password)
server.send_message(msg)
logger.info(f"Email sent successfully to {to_email}")
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
raise
def create_welcome_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
"""Create welcome email content for new user.
Args:
username: Username of the new user
password: Auto-generated password
login_url: URL to login page
Returns:
Tuple of (subject, text_body, html_body)
"""
subject = "Welcome to BamBuddy - Your Account Details"
text_body = f"""Welcome to BamBuddy!
Your account has been created. Here are your login details:
Username: {username}
Password: {password}
You can login at: {login_url}
For security reasons, please change your password after your first login.
Best regards,
BamBuddy Team
"""
html_body = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">Welcome to BamBuddy!</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
<p style="font-size: 16px;">Your account has been created. Here are your login details:</p>
<div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
<p style="margin: 0;"><strong>Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
</div>
<p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
<strong>Security Note:</strong> For security reasons, please change your password after your first login.
</p>
<p style="font-size: 14px; color: #999; margin-top: 30px;">
Best regards,<br>
BamBuddy Team
</p>
</div>
</body>
</html>
"""
return subject, text_body, html_body
def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
"""Create password reset email content.
Args:
username: Username of the user
password: New auto-generated password
login_url: URL to login page
Returns:
Tuple of (subject, text_body, html_body)
"""
subject = "BamBuddy - Your Password Has Been Reset"
text_body = f"""Your BamBuddy password has been reset.
Your login details:
Username: {username}
New Password: {password}
You can login at: {login_url}
For security reasons, please change your password after logging in.
If you did not request this password reset, please contact your administrator immediately.
Best regards,
BamBuddy Team
"""
html_body = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">Password Reset</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
<p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
<div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
<p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{login_url}" style="display: inline-block; background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
</div>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; font-size: 14px; color: #856404;">
<strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
</p>
</div>
<p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
<strong>Security Note:</strong> For security reasons, please change your password after logging in.
</p>
<p style="font-size: 14px; color: #999; margin-top: 30px;">
Best regards,<br>
BamBuddy Team
</p>
</div>
</body>
</html>
"""
return subject, text_body, html_body
+82 -1
View File
@@ -1843,6 +1843,7 @@ export interface LoginResponse {
export interface UserResponse {
id: number;
username: string;
email?: string;
role: string; // Deprecated, kept for backward compatibility
is_active: boolean;
is_admin: boolean; // Computed from role and group membership
@@ -1853,7 +1854,8 @@ export interface UserResponse {
export interface UserCreate {
username: string;
password: string;
password?: string; // Optional when advanced auth is enabled
email?: string;
role: string;
group_ids?: number[];
}
@@ -1861,6 +1863,7 @@ export interface UserCreate {
export interface UserUpdate {
username?: string;
password?: string;
email?: string;
role?: string;
is_active?: boolean;
group_ids?: number[];
@@ -1872,6 +1875,52 @@ export interface SetupRequest {
admin_password?: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ForgotPasswordResponse {
message: string;
}
export interface ResetPasswordRequest {
user_id: number;
}
export interface ResetPasswordResponse {
message: string;
}
export interface SMTPSettings {
smtp_host: string;
smtp_port: number;
smtp_username: string;
smtp_password?: string;
smtp_use_tls: boolean;
smtp_from_email: string;
smtp_from_name: string;
}
export interface TestSMTPRequest {
smtp_host: string;
smtp_port: number;
smtp_username: string;
smtp_password: string;
smtp_use_tls: boolean;
smtp_from_email: string;
test_recipient: string;
}
export interface TestSMTPResponse {
success: boolean;
message: string;
}
export interface AdvancedAuthStatus {
advanced_auth_enabled: boolean;
smtp_configured: boolean;
}
export interface SetupResponse {
auth_enabled: boolean;
admin_created?: boolean;
@@ -1905,6 +1954,38 @@ export const api = {
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
method: 'POST',
}),
// Advanced Authentication
testSMTP: (data: TestSMTPRequest) =>
request<TestSMTPResponse>('/auth/smtp/test', {
method: 'POST',
body: JSON.stringify(data),
}),
getSMTPSettings: () => request<SMTPSettings | null>('/auth/smtp'),
saveSMTPSettings: (data: SMTPSettings) =>
request<{ message: string }>('/auth/smtp', {
method: 'POST',
body: JSON.stringify(data),
}),
enableAdvancedAuth: () =>
request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/enable', {
method: 'POST',
}),
disableAdvancedAuth: () =>
request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/disable', {
method: 'POST',
}),
getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
forgotPassword: (data: ForgotPasswordRequest) =>
request<ForgotPasswordResponse>('/auth/forgot-password', {
method: 'POST',
body: JSON.stringify(data),
}),
resetUserPassword: (data: ResetPasswordRequest) =>
request<ResetPasswordResponse>('/auth/reset-password', {
method: 'POST',
body: JSON.stringify(data),
}),
// Users
getUsers: () => request<UserResponse[]>('/users/'),
+365
View File
@@ -0,0 +1,365 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Mail, Send, Lock, Unlock, AlertTriangle, CheckCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { SMTPSettings, TestSMTPRequest } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { useEffect } from 'react';
export function EmailSettings() {
const { t } = useTranslation();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [smtpSettings, setSMTPSettings] = useState<SMTPSettings>({
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_email: '',
smtp_from_name: 'BamBuddy',
});
const [testEmail, setTestEmail] = useState('');
// Fetch SMTP settings
const { data: existingSettings, isLoading } = useQuery({
queryKey: ['smtpSettings'],
queryFn: () => api.getSMTPSettings(),
});
// Fetch advanced auth status
const { data: advancedAuthStatus } = useQuery({
queryKey: ['advancedAuthStatus'],
queryFn: () => api.getAdvancedAuthStatus(),
});
// Load existing settings when fetched
useEffect(() => {
if (existingSettings) {
setSMTPSettings({
...existingSettings,
smtp_password: '', // Never show password
});
}
}, [existingSettings]);
// Save SMTP settings
const saveMutation = useMutation({
mutationFn: (settings: SMTPSettings) => api.saveSMTPSettings(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['smtpSettings'] });
queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });
showToast('SMTP settings saved successfully', 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Test SMTP connection
const testMutation = useMutation({
mutationFn: (request: TestSMTPRequest) => api.testSMTP(request),
onSuccess: (data) => {
showToast(data.message, data.success ? 'success' : 'error');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Toggle advanced auth
const toggleAdvancedAuthMutation = useMutation({
mutationFn: (enabled: boolean) =>
enabled ? api.enableAdvancedAuth() : api.disableAdvancedAuth(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['advancedAuthStatus'] });
showToast(data.message, 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleSave = () => {
// Validate required fields
if (!smtpSettings.smtp_host || !smtpSettings.smtp_username || !smtpSettings.smtp_from_email) {
showToast('Please fill in all required fields', 'error');
return;
}
saveMutation.mutate(smtpSettings);
};
const handleTest = () => {
if (!testEmail) {
showToast('Please enter a test email address', 'error');
return;
}
if (!smtpSettings.smtp_host || !smtpSettings.smtp_username || !smtpSettings.smtp_password || !smtpSettings.smtp_from_email) {
showToast('Please fill in all SMTP settings before testing', 'error');
return;
}
testMutation.mutate({
smtp_host: smtpSettings.smtp_host,
smtp_port: smtpSettings.smtp_port,
smtp_username: smtpSettings.smtp_username,
smtp_password: smtpSettings.smtp_password,
smtp_use_tls: smtpSettings.smtp_use_tls,
smtp_from_email: smtpSettings.smtp_from_email,
test_recipient: testEmail,
});
};
const handleToggleAdvancedAuth = () => {
if (!advancedAuthStatus?.advanced_auth_enabled && !advancedAuthStatus?.smtp_configured) {
showToast('Please configure and test SMTP settings first', 'error');
return;
}
toggleAdvancedAuthMutation.mutate(!advancedAuthStatus?.advanced_auth_enabled);
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
return (
<div className="space-y-6">
{/* Advanced Authentication Toggle */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">
{t('settings.email.advancedAuth') || 'Advanced Authentication'}
</h2>
</div>
<Button
onClick={handleToggleAdvancedAuth}
disabled={toggleAdvancedAuthMutation.isPending}
variant={advancedAuthStatus?.advanced_auth_enabled ? 'danger' : 'primary'}
>
{advancedAuthStatus?.advanced_auth_enabled ? (
<>
<Unlock className="w-4 h-4" />
{t('settings.email.disable') || 'Disable'}
</>
) : (
<>
<Lock className="w-4 h-4" />
{t('settings.email.enable') || 'Enable'}
</>
)}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{advancedAuthStatus?.advanced_auth_enabled ? (
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-white font-medium">
{t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'}
</p>
<ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
<li>{t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}</li>
<li>{t('settings.email.feature2') || 'Users can login with username or email'}</li>
<li>{t('settings.email.feature3') || 'Forgot password feature is available'}</li>
<li>{t('settings.email.feature4') || 'Admins can reset user passwords via email'}</li>
</ul>
</div>
</div>
</div>
) : (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="space-y-2">
<p className="text-white font-medium">
{t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'}
</p>
<p className="text-sm text-yellow-300">
{t('settings.email.advancedAuthDisabledDesc') || 'Configure and test SMTP settings below, then enable advanced authentication to activate email-based features.'}
</p>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* SMTP Configuration */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">
{t('settings.email.smtpSettings') || 'SMTP Configuration'}
</h2>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.smtpHost') || 'SMTP Server'} *
</label>
<input
type="text"
value={smtpSettings.smtp_host}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_host: e.target.value })}
placeholder="smtp.gmail.com"
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.smtpPort') || 'Port'} *
</label>
<input
type="number"
value={smtpSettings.smtp_port}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_port: parseInt(e.target.value) || 587 })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.username') || 'Username'} *
</label>
<input
type="text"
value={smtpSettings.smtp_username}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_username: e.target.value })}
placeholder="your.email@gmail.com"
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.password') || 'Password'} *
</label>
<input
type="password"
value={smtpSettings.smtp_password}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_password: e.target.value })}
placeholder={existingSettings ? '••••••••' : 'Enter password'}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.fromEmail') || 'From Email'} *
</label>
<input
type="email"
value={smtpSettings.smtp_from_email}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_email: e.target.value })}
placeholder="noreply@yourdomain.com"
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.fromName') || 'From Name'}
</label>
<input
type="text"
value={smtpSettings.smtp_from_name}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_from_name: e.target.value })}
placeholder="BamBuddy"
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use_tls"
checked={smtpSettings.smtp_use_tls}
onChange={(e) => setSMTPSettings({ ...smtpSettings, smtp_use_tls: e.target.checked })}
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
/>
<label htmlFor="use_tls" className="text-sm text-white">
{t('settings.email.useTLS') || 'Use TLS (recommended)'}
</label>
</div>
<div className="flex gap-2">
<Button
onClick={handleSave}
disabled={saveMutation.isPending}
className="flex-1"
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('settings.email.saving') || 'Saving...'}
</>
) : (
t('settings.email.save') || 'Save Settings'
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Test SMTP */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-white">
{t('settings.email.testConnection') || 'Test SMTP Connection'}
</h2>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('settings.email.testRecipient') || 'Test Recipient Email'}
</label>
<input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="test@example.com"
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
/>
</div>
<Button
onClick={handleTest}
disabled={testMutation.isPending}
variant="secondary"
>
{testMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('settings.email.sending') || 'Sending...'}
</>
) : (
<>
<Send className="w-4 h-4" />
{t('settings.email.sendTest') || 'Send Test Email'}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+119 -36
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
import { HelpCircle, X } from 'lucide-react';
import { X, Mail } from 'lucide-react';
import { api } from '../api/client';
export function LoginPage() {
const navigate = useNavigate();
@@ -16,6 +17,13 @@ export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState('');
// Check if advanced auth is enabled
const { data: advancedAuthStatus } = useQuery({
queryKey: ['advancedAuthStatus'],
queryFn: () => api.getAdvancedAuthStatus(),
});
const loginMutation = useMutation({
mutationFn: () => login(username, password),
@@ -28,6 +36,18 @@ export function LoginPage() {
},
});
const forgotPasswordMutation = useMutation({
mutationFn: (email: string) => api.forgotPassword({ email }),
onSuccess: (data) => {
showToast(data.message, 'success');
setShowForgotPassword(false);
setForgotEmail('');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
@@ -37,6 +57,15 @@ export function LoginPage() {
loginMutation.mutate();
};
const handleForgotPassword = (e: React.FormEvent) => {
e.preventDefault();
if (!forgotEmail) {
showToast('Please enter your email address', 'error');
return;
}
forgotPasswordMutation.mutate(forgotEmail);
};
return (
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
@@ -60,7 +89,9 @@ export function LoginPage() {
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
{t('login.username')}
{advancedAuthStatus?.advanced_auth_enabled
? t('login.usernameOrEmail') || 'Username or Email'
: t('login.username')}
</label>
<input
id="username"
@@ -69,7 +100,9 @@ export function LoginPage() {
value={username}
onChange={(e) => setUsername(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('login.usernamePlaceholder')}
placeholder={advancedAuthStatus?.advanced_auth_enabled
? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email'
: t('login.usernamePlaceholder')}
autoComplete="username"
/>
</div>
@@ -101,22 +134,24 @@ export function LoginPage() {
</button>
</div>
<div className="text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
>
{t('login.forgotPassword')}
</button>
</div>
{advancedAuthStatus?.advanced_auth_enabled && (
<div className="text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
>
{t('login.forgotPassword')}
</button>
</div>
)}
</form>
</div>
{/* Forgot Password Modal */}
{showForgotPassword && (
<div
className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setShowForgotPassword(false)}
>
<div
@@ -125,39 +160,87 @@ export function LoginPage() {
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-bambu-green" />
<Mail className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
</div>
<button
onClick={() => setShowForgotPassword(false)}
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
className="p-1 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<p className="text-bambu-gray">
{t('login.forgotPasswordMessage')}
</p>
{advancedAuthStatus?.advanced_auth_enabled ? (
<form onSubmit={handleForgotPassword} className="space-y-4">
<p className="text-bambu-gray text-sm">
{t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'}
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>{t('login.resetStep1')}</li>
<li>{t('login.resetStep2')}</li>
<li>{t('login.resetStep3')}</li>
<li>{t('login.resetStep4')}</li>
</ol>
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-white mb-2">
{t('login.emailAddress') || 'Email Address'}
</label>
<input
id="forgot-email"
type="email"
required
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('login.emailPlaceholder') || 'your.email@example.com'}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
}}
className="flex-1 py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
{t('login.cancel') || 'Cancel'}
</button>
<button
type="submit"
disabled={forgotPasswordMutation.isPending}
className="flex-1 py-2 px-4 bg-bambu-green hover:bg-bambu-green-light text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{forgotPasswordMutation.isPending
? (t('login.sending') || 'Sending...')
: (t('login.sendResetEmail') || 'Send Reset Email')}
</button>
</div>
</form>
) : (
<div className="space-y-4">
<p className="text-bambu-gray">
{t('login.forgotPasswordMessage')}
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>{t('login.resetStep1')}</li>
<li>{t('login.resetStep2')}</li>
<li>{t('login.resetStep3')}</li>
<li>{t('login.resetStep4')}</li>
</ol>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
{t('login.gotIt')}
</button>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
{t('login.gotIt')}
</button>
</div>
)}
</div>
</div>
)}
+19 -2
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, ChevronRight, Check, Save } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, ChevronRight, Check, Save, Mail } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
@@ -19,6 +19,7 @@ import { SpoolmanSettings } from '../components/SpoolmanSettings';
import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
import { EmailSettings } from '../components/EmailSettings';
import { APIBrowser } from '../components/APIBrowser';
import { virtualPrinterApi } from '../api/client';
import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
@@ -28,7 +29,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'email', 'backup'] as const;
type TabType = typeof validTabs[number];
export function SettingsPage() {
@@ -1025,6 +1026,17 @@ export function SettingsPage() {
<span className="w-2 h-2 rounded-full bg-green-400" />
)}
</button>
<button
onClick={() => handleTabChange('email')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
activeTab === 'email'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Mail className="w-4 h-4" />
{t('settings.tabs.email') || 'Email'}
</button>
<button
onClick={() => handleTabChange('backup')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -4179,6 +4191,11 @@ export function SettingsPage() {
/>
)}
{/* Email Tab */}
{activeTab === 'email' && (
<EmailSettings />
)}
{/* Backup Tab */}
{activeTab === 'backup' && (
<GitHubBackupSettings />
+144 -52
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';
import { api } from '../api/client';
import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
@@ -14,6 +14,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
interface FormData extends UserCreate {
group_ids: number[];
confirmPassword: string;
email?: string;
}
export function UsersPage() {
@@ -29,23 +30,30 @@ export function UsersPage() {
const [formData, setFormData] = useState<FormData>({
username: '',
password: '',
email: '',
confirmPassword: '',
role: 'user',
group_ids: [],
});
// Check if advanced auth is enabled
const { data: advancedAuthStatus } = useQuery({
queryKey: ['advancedAuthStatus'],
queryFn: () => api.getAdvancedAuthStatus(),
});
// Close modal on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showCreateModal) {
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}
if (showEditModal) {
setShowEditModal(false);
setEditingUserId(null);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}
}
};
@@ -71,7 +79,7 @@ export function UsersPage() {
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['groups'] });
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
showToast(t('users.toast.created'));
},
onError: (error: Error) => {
@@ -86,7 +94,7 @@ export function UsersPage() {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setShowEditModal(false);
setEditingUserId(null);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
showToast(t('users.toast.updated'));
},
onError: (error: Error) => {
@@ -105,22 +113,51 @@ export function UsersPage() {
},
});
const resetPasswordMutation = useMutation({
mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
onSuccess: (data) => {
showToast(data.message, 'success');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleCreate = () => {
if (!formData.username || !formData.password) {
// Use the status from the query hook
const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
if (!formData.username) {
showToast(t('users.toast.fillRequired'), 'error');
return;
}
if (formData.password !== formData.confirmPassword) {
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
// Email is required when advanced auth is enabled
if (advancedAuthEnabled && !formData.email) {
showToast('Email is required when advanced authentication is enabled', 'error');
return;
}
if (formData.password.length < 6) {
showToast(t('users.toast.passwordTooShort'), 'error');
return;
// Password validation only when advanced auth is disabled
if (!advancedAuthEnabled) {
if (!formData.password) {
showToast(t('users.toast.fillRequired'), 'error');
return;
}
if (formData.password !== formData.confirmPassword) {
showToast(t('users.toast.passwordsDoNotMatch'), 'error');
return;
}
if (formData.password.length < 6) {
showToast(t('users.toast.passwordTooShort'), 'error');
return;
}
}
createMutation.mutate({
username: formData.username,
password: formData.password,
password: advancedAuthEnabled ? undefined : formData.password,
email: formData.email || undefined,
role: formData.role,
group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
});
@@ -141,6 +178,7 @@ export function UsersPage() {
const updateData: UserUpdate = {
username: formData.username || undefined,
password: formData.password || undefined,
email: formData.email || undefined,
role: formData.role,
group_ids: formData.group_ids,
};
@@ -148,6 +186,10 @@ export function UsersPage() {
if (!updateData.password) {
delete updateData.password;
}
// Remove email if empty
if (!updateData.email) {
delete updateData.email;
}
updateMutation.mutate({ id, data: updateData });
};
@@ -160,6 +202,7 @@ export function UsersPage() {
setFormData({
username: user.username,
password: '',
email: user.email || '',
confirmPassword: '',
role: user.role,
group_ids: user.groups?.map(g => g.id) || [],
@@ -170,7 +213,7 @@ export function UsersPage() {
const closeEditModal = () => {
setShowEditModal(false);
setEditingUserId(null);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
};
const toggleGroup = (groupId: number) => {
@@ -221,7 +264,7 @@ export function UsersPage() {
<Button
onClick={() => {
setShowCreateModal(true);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<Plus className="w-4 h-4" />
@@ -316,6 +359,17 @@ export function UsersPage() {
{t('users.delete')}
</Button>
)}
{advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
<Button
size="sm"
variant="ghost"
onClick={() => resetPasswordMutation.mutate(user.id)}
disabled={resetPasswordMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
{t('users.resetPassword') || 'Reset Password'}
</Button>
)}
</div>
</td>
</tr>
@@ -332,7 +386,7 @@ export function UsersPage() {
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<Card
@@ -350,7 +404,7 @@ export function UsersPage() {
size="sm"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<X className="w-5 h-5" />
@@ -372,41 +426,67 @@ export function UsersPage() {
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password')}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('users.form.passwordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.confirmPassword')}
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
formData.confirmPassword && formData.password !== formData.confirmPassword
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder={t('users.form.confirmPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
{advancedAuthStatus?.advanced_auth_enabled && (
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.email') || 'Email'}
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
required={advancedAuthStatus?.advanced_auth_enabled}
/>
</div>
)}
{!advancedAuthStatus?.advanced_auth_enabled && (
<>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password')}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('users.form.passwordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.confirmPassword')}
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
formData.confirmPassword && formData.password !== formData.confirmPassword
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder={t('users.form.confirmPasswordPlaceholder')}
autoComplete="new-password"
minLength={6}
/>
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
</>
)}
{advancedAuthStatus?.advanced_auth_enabled && (
<div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3">
<p className="text-sm text-bambu-gray">
{t('users.form.autoGeneratedPassword') || 'A secure password will be automatically generated and emailed to the user.'}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.groups')}
@@ -440,7 +520,7 @@ export function UsersPage() {
variant="secondary"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
{t('users.modal.cancel')}
@@ -507,6 +587,18 @@ export function UsersPage() {
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.email') || 'Email'} <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BZQD54OI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-togsBDt6.css">
<script type="module" crossorigin src="/assets/index-Ce6bi4R2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-962x7uln.css">
</head>
<body>
<div id="root"></div>