diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 7036a3d20..5d1694bc4 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -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="

This is a test email from BamBuddy.

If you received this, your SMTP settings are working correctly!

", + ) + + 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)}", + ) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 372e7e188..4a82ca776 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -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) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index beef0be28..99690e8f6 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -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: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 33ad90a4c..d1f496ea8 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 02811ea7f..99c40d711 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 000000000..0c51414b4 --- /dev/null +++ b/backend/app/services/email_service.py @@ -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""" + + + + + + +
+

Welcome to BamBuddy!

+
+
+

Your account has been created. Here are your login details:

+ +
+

Username: {username}

+

Password: {password}

+
+ +
+ Login Now +
+ +

+ Security Note: For security reasons, please change your password after your first login. +

+ +

+ Best regards,
+ BamBuddy Team +

+
+ + +""" + + 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""" + + + + + + +
+

Password Reset

+
+
+

Your BamBuddy password has been reset.

+ +
+

Username: {username}

+

New Password: {password}

+
+ +
+ Login Now +
+ +
+

+ ⚠️ Security Alert: If you did not request this password reset, please contact your administrator immediately. +

+
+ +

+ Security Note: For security reasons, please change your password after logging in. +

+ +

+ Best regards,
+ BamBuddy Team +

+
+ + +""" + + return subject, text_body, html_body diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 883d8a7aa..7741e816e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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('/auth/smtp/test', { + method: 'POST', + body: JSON.stringify(data), + }), + getSMTPSettings: () => request('/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('/auth/advanced-auth/status'), + forgotPassword: (data: ForgotPasswordRequest) => + request('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify(data), + }), + resetUserPassword: (data: ResetPasswordRequest) => + request('/auth/reset-password', { + method: 'POST', + body: JSON.stringify(data), + }), // Users getUsers: () => request('/users/'), diff --git a/frontend/src/components/EmailSettings.tsx b/frontend/src/components/EmailSettings.tsx new file mode 100644 index 000000000..53ee984f2 --- /dev/null +++ b/frontend/src/components/EmailSettings.tsx @@ -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({ + 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 ( +
+ +
+ ); + } + + return ( +
+ {/* Advanced Authentication Toggle */} + + +
+
+ +

+ {t('settings.email.advancedAuth') || 'Advanced Authentication'} +

+
+ +
+
+ +
+ {advancedAuthStatus?.advanced_auth_enabled ? ( +
+
+ +
+

+ {t('settings.email.advancedAuthEnabled') || 'Advanced Authentication is enabled'} +

+
    +
  • {t('settings.email.feature1') || 'Passwords are auto-generated and emailed to new users'}
  • +
  • {t('settings.email.feature2') || 'Users can login with username or email'}
  • +
  • {t('settings.email.feature3') || 'Forgot password feature is available'}
  • +
  • {t('settings.email.feature4') || 'Admins can reset user passwords via email'}
  • +
+
+
+
+ ) : ( +
+
+ +
+

+ {t('settings.email.advancedAuthDisabled') || 'Advanced Authentication is disabled'} +

+

+ {t('settings.email.advancedAuthDisabledDesc') || 'Configure and test SMTP settings below, then enable advanced authentication to activate email-based features.'} +

+
+
+
+ )} +
+
+
+ + {/* SMTP Configuration */} + + +

+ {t('settings.email.smtpSettings') || 'SMTP Configuration'} +

+
+ +
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ 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" + /> + +
+ +
+ +
+
+
+
+ + {/* Test SMTP */} + + +

+ {t('settings.email.testConnection') || 'Test SMTP Connection'} +

+
+ +
+
+ + 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" + /> +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 50df96aec..d461c2672 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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 (
@@ -60,7 +89,9 @@ export function LoginPage() {
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" />
@@ -101,22 +134,24 @@ export function LoginPage() {
-
- -
+ {advancedAuthStatus?.advanced_auth_enabled && ( +
+ +
+ )}
{/* Forgot Password Modal */} {showForgotPassword && (
setShowForgotPassword(false)} >
- +

{t('login.forgotPasswordTitle')}

-
-

- {t('login.forgotPasswordMessage')} -

+ {advancedAuthStatus?.advanced_auth_enabled ? ( +
+

+ {t('login.forgotPasswordEmailMessage') || 'Enter your email address and we\'ll send you a new password.'} +

-
-

{t('login.howToReset')}

-
    -
  1. {t('login.resetStep1')}
  2. -
  3. {t('login.resetStep2')}
  4. -
  5. {t('login.resetStep3')}
  6. -
  7. {t('login.resetStep4')}
  8. -
+
+ + 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'} + /> +
+ +
+ + +
+ + ) : ( +
+

+ {t('login.forgotPasswordMessage')} +

+ +
+

{t('login.howToReset')}

+
    +
  1. {t('login.resetStep1')}
  2. +
  3. {t('login.resetStep2')}
  4. +
  5. {t('login.resetStep3')}
  6. +
  7. {t('login.resetStep4')}
  8. +
+
+ +
- - -
+ )}
)} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 6d7f7468b..3d6e14a33 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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() { )} + )} + {advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && ( + + )}
@@ -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: [] }); }} > { setShowCreateModal(false); - setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] }); + setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] }); }} > @@ -372,41 +426,67 @@ export function UsersPage() { autoComplete="username" />
-
- - 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} - /> -
-
- - 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 && ( -

{t('users.toast.passwordsDoNotMatch')}

- )} -
+ {advancedAuthStatus?.advanced_auth_enabled && ( +
+ + 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} + /> +
+ )} + {!advancedAuthStatus?.advanced_auth_enabled && ( + <> +
+ + 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} + /> +
+
+ + 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 && ( +

{t('users.toast.passwordsDoNotMatch')}

+ )} +
+ + )} + {advancedAuthStatus?.advanced_auth_enabled && ( +
+

+ {t('users.form.autoGeneratedPassword') || 'A secure password will be automatically generated and emailed to the user.'} +

+
+ )}
+
+ + 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'} + /> +