diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8d78920f..6d9e2707d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -71,6 +71,14 @@ All notable changes to Bambuddy will be documented in this file.
- Three-dot menu button always visible on mobile (hover-only on desktop)
- Selection checkbox always visible on mobile devices
- Better PWA experience for file management
+- **Optional Authentication** - Secure your Bambuddy instance with user authentication:
+ - Enable/disable authentication via Setup page or Settings → Users
+ - Role-based access control: Admin and User roles
+ - Admins have full access; Users can manage prints but not settings
+ - JWT-based authentication with 7-day token expiration
+ - User management page for creating, editing, and deleting users
+ - Backward compatible: existing installations work without authentication
+ - Settings page restricted to admin users when auth is enabled
### Changed
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
diff --git a/README.md b/README.md
index f1e29a296..8a72dce98 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,12 @@
- Live application log viewer with filtering
- Support bundle generator (privacy-filtered)
+### 🔒 Optional Authentication
+- Enable/disable authentication any time
+- Role-based access (Admin/User)
+- JWT tokens with secure password hashing
+- User management (create, edit, delete)
+
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
index b50e332f3..473ca0ef4 100644
--- a/backend/app/api/routes/auth.py
+++ b/backend/app/api/routes/auth.py
@@ -24,7 +24,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
"""Check if authentication is enabled."""
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
setting = result.scalar_one_or_none()
- return setting and setting.value.lower() == "true"
+ if setting is None:
+ return False
+ return setting.value.lower() == "true"
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 4517a870e..5bcc66b41 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -25,6 +25,7 @@ from backend.app.models.project import Project
from backend.app.models.project_bom import ProjectBOMItem
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
from backend.app.services.printer_manager import printer_manager
from backend.app.services.spoolman import init_spoolman_client
@@ -242,6 +243,9 @@ async def export_backup(
include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
+ include_users: bool = Query(
+ False, description="Include users (passwords not exported - users will need new passwords)"
+ ),
):
"""Export selected data as JSON backup."""
backup: dict = {
@@ -776,6 +780,22 @@ async def export_backup(
)
backup["included"].append("api_keys")
+ # Users (note: passwords not exported for security - users will need new passwords on import)
+ if include_users:
+ result = await db.execute(select(User))
+ users = result.scalars().all()
+ backup["users"] = []
+ for user in users:
+ backup["users"].append(
+ {
+ "username": user.username,
+ "role": user.role,
+ "is_active": user.is_active,
+ # password_hash intentionally not exported for security
+ }
+ )
+ backup["included"].append("users")
+
# If there are files to include (icons or archives), create ZIP file
if backup_files:
zip_buffer = io.BytesIO()
@@ -866,6 +886,7 @@ async def import_backup(
"maintenance_types": 0,
"projects": 0,
"pending_uploads": 0,
+ "users": 0,
}
skipped = {
"settings": 0,
@@ -879,6 +900,7 @@ async def import_backup(
"archives": 0,
"projects": 0,
"pending_uploads": 0,
+ "users": 0,
}
skipped_details = {
"notification_providers": [],
@@ -890,6 +912,7 @@ async def import_backup(
"archives": [],
"projects": [],
"pending_uploads": [],
+ "users": [],
}
# Restore settings (always overwrites)
@@ -1772,6 +1795,40 @@ async def import_backup(
}
)
+ # Restore users (note: passwords not included in backup - users will need new passwords)
+ # Users are skipped by default since they have no passwords; admin must recreate them
+ new_users: list[str] = []
+ if "users" in backup:
+ from backend.app.core.auth import get_password_hash
+
+ for user_data in backup["users"]:
+ result = await db.execute(select(User).where(User.username == user_data["username"]))
+ existing = result.scalar_one_or_none()
+ if existing:
+ if overwrite:
+ existing.role = user_data.get("role", "user")
+ existing.is_active = user_data.get("is_active", True)
+ # Don't change password - keep existing
+ restored["users"] += 1
+ else:
+ skipped["users"] += 1
+ skipped_details["users"].append(user_data["username"])
+ else:
+ # Create user with a temporary password that must be changed
+ # Generate a random temporary password
+ import secrets
+
+ temp_password = secrets.token_urlsafe(16)
+ user = User(
+ username=user_data["username"],
+ password_hash=get_password_hash(temp_password),
+ role=user_data.get("role", "user"),
+ is_active=user_data.get("is_active", True),
+ )
+ db.add(user)
+ restored["users"] += 1
+ new_users.append(f"{user_data['username']} (temp password: {temp_password})")
+
await db.commit()
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1882,6 +1939,10 @@ async def import_backup(
if new_api_keys:
response["new_api_keys"] = new_api_keys
+ # Include newly created users with temp passwords (so admin can share them)
+ if new_users:
+ response["new_users"] = new_users
+
return response
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index 850e966c1..f79a66b49 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -81,7 +81,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
try:
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
setting = result.scalar_one_or_none()
- return setting and setting.value.lower() == "true"
+ if setting is None:
+ return False
+ return setting.value.lower() == "true"
except Exception:
# If settings table doesn't exist or query fails, assume auth is disabled
return False
@@ -110,13 +112,17 @@ async def get_current_user_optional(
return user
-async def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> User:
+async def get_current_user(
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+) -> User:
"""Get the current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
+ if credentials is None:
+ raise credentials_exception
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py
new file mode 100644
index 000000000..f6349b043
--- /dev/null
+++ b/backend/tests/integration/test_auth_api.py
@@ -0,0 +1,362 @@
+"""Integration tests for Authentication API endpoints.
+
+Tests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestAuthStatusAPI:
+ """Integration tests for /api/v1/auth/status endpoint."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_get_auth_status_disabled(self, async_client: AsyncClient):
+ """Verify auth status returns disabled when not configured."""
+ response = await async_client.get("/api/v1/auth/status")
+
+ assert response.status_code == 200
+ result = response.json()
+ assert "auth_enabled" in result
+ assert result["auth_enabled"] is False
+ assert result["requires_setup"] is True
+
+
+class TestAuthSetupAPI:
+ """Integration tests for /api/v1/auth/setup endpoint."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_setup_auth_disabled(self, async_client: AsyncClient):
+ """Verify auth can be set up with auth disabled (no password required)."""
+ response = await async_client.post(
+ "/api/v1/auth/setup",
+ json={"auth_enabled": False},
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["auth_enabled"] is False
+ assert result["admin_created"] is False
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):
+ """Verify enabling auth requires admin username and password."""
+ response = await async_client.post(
+ "/api/v1/auth/setup",
+ json={"auth_enabled": True},
+ )
+
+ assert response.status_code == 400
+ assert "Admin username and password are required" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):
+ """Verify auth can be enabled with admin credentials."""
+ response = await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "testadmin",
+ "admin_password": "testpassword123",
+ },
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["auth_enabled"] is True
+ assert result["admin_created"] is True
+
+
+class TestAuthLoginAPI:
+ """Integration tests for /api/v1/auth/login endpoint."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_login_auth_disabled(self, async_client: AsyncClient):
+ """Verify login fails when auth is not enabled."""
+ response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "admin", "password": "password"},
+ )
+
+ assert response.status_code == 400
+ assert "Authentication is not enabled" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_login_success(self, async_client: AsyncClient):
+ """Verify login succeeds with valid credentials after setup."""
+ # First enable auth
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "logintest",
+ "admin_password": "loginpassword123",
+ },
+ )
+
+ # Now login
+ response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "logintest", "password": "loginpassword123"},
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert "access_token" in result
+ assert result["token_type"] == "bearer"
+ assert result["user"]["username"] == "logintest"
+ assert result["user"]["role"] == "admin"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_login_invalid_credentials(self, async_client: AsyncClient):
+ """Verify login fails with invalid credentials."""
+ # First enable auth
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "invalidtest",
+ "admin_password": "correctpassword",
+ },
+ )
+
+ # Try login with wrong password
+ response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "invalidtest", "password": "wrongpassword"},
+ )
+
+ assert response.status_code == 401
+ assert "Incorrect username or password" in response.json()["detail"]
+
+
+class TestAuthMeAPI:
+ """Integration tests for /api/v1/auth/me endpoint."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_me_without_token(self, async_client: AsyncClient):
+ """Verify /me fails without authentication token."""
+ response = await async_client.get("/api/v1/auth/me")
+
+ assert response.status_code == 401
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_me_with_valid_token(self, async_client: AsyncClient):
+ """Verify /me returns user info with valid token."""
+ # Setup and login
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "metest",
+ "admin_password": "mepassword123",
+ },
+ )
+
+ login_response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "metest", "password": "mepassword123"},
+ )
+ token = login_response.json()["access_token"]
+
+ # Get current user
+ response = await async_client.get(
+ "/api/v1/auth/me",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert result["username"] == "metest"
+ assert result["role"] == "admin"
+ assert result["is_active"] is True
+
+
+class TestUsersAPI:
+ """Integration tests for /api/v1/users/ endpoints."""
+
+ @pytest.fixture
+ async def auth_token(self, async_client: AsyncClient):
+ """Setup auth and return admin token."""
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "usersadmin",
+ "admin_password": "adminpassword123",
+ },
+ )
+
+ login_response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "usersadmin", "password": "adminpassword123"},
+ )
+ return login_response.json()["access_token"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_list_users_requires_auth(self, async_client: AsyncClient):
+ """Verify listing users requires authentication."""
+ response = await async_client.get("/api/v1/users/")
+
+ assert response.status_code == 401
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):
+ """Verify admin can list users."""
+ response = await async_client.get(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ )
+
+ assert response.status_code == 200
+ result = response.json()
+ assert isinstance(result, list)
+ assert len(result) >= 1 # At least the admin user
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_create_user(self, async_client: AsyncClient, auth_token: str):
+ """Verify admin can create a new user."""
+ response = await async_client.post(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={
+ "username": "newuser",
+ "password": "newuserpassword",
+ "role": "user",
+ },
+ )
+
+ assert response.status_code == 201
+ result = response.json()
+ assert result["username"] == "newuser"
+ assert result["role"] == "user"
+ assert result["is_active"] is True
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):
+ """Verify creating user with duplicate username fails."""
+ # Create first user
+ await async_client.post(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={
+ "username": "duplicateuser",
+ "password": "password123",
+ "role": "user",
+ },
+ )
+
+ # Try to create duplicate
+ response = await async_client.post(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={
+ "username": "duplicateuser",
+ "password": "password456",
+ "role": "user",
+ },
+ )
+
+ assert response.status_code == 400
+ assert "Username already exists" in response.json()["detail"]
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_update_user(self, async_client: AsyncClient, auth_token: str):
+ """Verify admin can update a user."""
+ # Create user
+ create_response = await async_client.post(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={
+ "username": "updateuser",
+ "password": "password123",
+ "role": "user",
+ },
+ )
+ user_id = create_response.json()["id"]
+
+ # Update user
+ response = await async_client.patch(
+ f"/api/v1/users/{user_id}",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={"role": "admin"},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["role"] == "admin"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_delete_user(self, async_client: AsyncClient, auth_token: str):
+ """Verify admin can delete a user."""
+ # Create user
+ create_response = await async_client.post(
+ "/api/v1/users/",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ json={
+ "username": "deleteuser",
+ "password": "password123",
+ "role": "user",
+ },
+ )
+ user_id = create_response.json()["id"]
+
+ # Delete user
+ response = await async_client.delete(
+ f"/api/v1/users/{user_id}",
+ headers={"Authorization": f"Bearer {auth_token}"},
+ )
+
+ assert response.status_code == 204
+
+
+class TestAuthDisableAPI:
+ """Integration tests for /api/v1/auth/disable endpoint."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_disable_auth(self, async_client: AsyncClient):
+ """Verify admin can disable authentication."""
+ # Setup auth
+ await async_client.post(
+ "/api/v1/auth/setup",
+ json={
+ "auth_enabled": True,
+ "admin_username": "disableadmin",
+ "admin_password": "adminpassword123",
+ },
+ )
+
+ # Login to get token
+ login_response = await async_client.post(
+ "/api/v1/auth/login",
+ json={"username": "disableadmin", "password": "adminpassword123"},
+ )
+ token = login_response.json()["access_token"]
+
+ # Disable auth
+ response = await async_client.post(
+ "/api/v1/auth/disable",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 200
+ assert response.json()["auth_enabled"] is False
+
+ # Verify auth is now disabled
+ status_response = await async_client.get("/api/v1/auth/status")
+ assert status_response.json()["auth_enabled"] is False
diff --git a/frontend/src/__tests__/pages/LoginPage.test.tsx b/frontend/src/__tests__/pages/LoginPage.test.tsx
new file mode 100644
index 000000000..3e119101a
--- /dev/null
+++ b/frontend/src/__tests__/pages/LoginPage.test.tsx
@@ -0,0 +1,157 @@
+/**
+ * Tests for the LoginPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { LoginPage } from '../../pages/LoginPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('LoginPage', () => {
+ beforeEach(() => {
+ server.use(
+ http.get('/api/v1/auth/status', () => {
+ return HttpResponse.json({ auth_enabled: true, requires_setup: false });
+ })
+ );
+ });
+
+ describe('rendering', () => {
+ it('renders the login form', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
+ });
+
+ expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+ });
+
+ it('renders the sign in description', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('form validation', () => {
+ it('shows error when submitting empty form', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+ // The form has required fields, so HTML5 validation should prevent submission
+ // or the component shows a toast
+ });
+
+ it('allows entering username and password', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+ });
+
+ await user.type(screen.getByLabelText(/Username/i), 'testuser');
+ await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+
+ expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
+ expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
+ });
+ });
+
+ describe('login flow', () => {
+ it('submits login request with credentials', async () => {
+ const user = userEvent.setup();
+ let loginCalled = false;
+
+ server.use(
+ http.post('/api/v1/auth/login', async ({ request }) => {
+ loginCalled = true;
+ const body = await request.json() as { username: string; password: string };
+ if (body.username === 'validuser' && body.password === 'validpass') {
+ return HttpResponse.json({
+ access_token: 'test-token',
+ token_type: 'bearer',
+ user: {
+ id: 1,
+ username: 'validuser',
+ role: 'admin',
+ is_active: true,
+ created_at: new Date().toISOString(),
+ },
+ });
+ }
+ return HttpResponse.json(
+ { detail: 'Incorrect username or password' },
+ { status: 401 }
+ );
+ })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+ });
+
+ await user.type(screen.getByLabelText(/Username/i), 'validuser');
+ await user.type(screen.getByLabelText(/Password/i), 'validpass');
+ await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+ // Verify the login endpoint was called
+ await waitFor(() => {
+ expect(loginCalled).toBe(true);
+ });
+ });
+
+ it('shows loading state during login', async () => {
+ const user = userEvent.setup();
+
+ // Slow login endpoint
+ server.use(
+ http.post('/api/v1/auth/login', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return HttpResponse.json({
+ access_token: 'test-token',
+ token_type: 'bearer',
+ user: {
+ id: 1,
+ username: 'testuser',
+ role: 'admin',
+ is_active: true,
+ created_at: new Date().toISOString(),
+ },
+ });
+ })
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+ });
+
+ await user.type(screen.getByLabelText(/Username/i), 'testuser');
+ await user.type(screen.getByLabelText(/Password/i), 'testpass');
+ await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+ // Check for loading state
+ await waitFor(() => {
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+ });
+ });
+});