diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index f5e43cd..260fb1c 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -390,6 +390,22 @@
"replace_warning_title": "Replace all data?",
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
},
+ "auth": {
+ "title": "Authentication",
+ "description": "Require users to log in to access Spoolman. Once enabled, you'll be redirected to the login page.",
+ "status_enabled": "Authentication is enabled",
+ "status_disabled": "Authentication is disabled",
+ "setup_title": "Enable Authentication",
+ "setup_description": "Create an admin account to enable authentication. This user will have full access to manage Spoolman.",
+ "enable_button": "Create Admin & Enable",
+ "disable_title": "Disable Authentication",
+ "disable_description": "Enter an admin password to confirm disabling authentication. Anyone will be able to access Spoolman without logging in.",
+ "disable_button": "Disable Authentication",
+ "admin_password": "Admin Password",
+ "password_required": "Password is required",
+ "enabled_success": "Authentication enabled",
+ "disabled_success": "Authentication disabled"
+ },
"extra_fields": {
"tab": "Extra Fields",
"description": "
Here you can add extra custom fields to your entities.
Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.
The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.
Extra fields can not be sorted or filtered in the table views.
",
@@ -449,7 +465,7 @@
"role_editor": "Editor - Can view, use, create, and edit",
"role_admin": "Admin - Full access including user management",
"auth_disabled": "Authentication is not enabled",
- "auth_disabled_hint": "Set SPOOLMAN_AUTH_ENABLED=true to enable user management",
+ "auth_disabled_hint": "Enable authentication in Settings > General to manage users",
"admin_required": "Admin Access Required",
"admin_required_hint": "Only administrators can manage users",
"logout": "Log Out"
diff --git a/client/src/pages/settings/generalSettings.tsx b/client/src/pages/settings/generalSettings.tsx
index e5100e0..b4c1cc1 100644
--- a/client/src/pages/settings/generalSettings.tsx
+++ b/client/src/pages/settings/generalSettings.tsx
@@ -1,11 +1,12 @@
-import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
+import { DownloadOutlined, LockOutlined, UploadOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
-import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
+import { Button, Checkbox, Divider, Form, Input, message, Modal, Popconfirm, Space, Switch, Upload } from "antd";
import type { UploadFile } from "antd";
import { useContext, useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
import { ColorModeContext, THEME_COLORS, ThemeColorKey } from "../../contexts/color-mode";
+import { useAuth } from "../../contexts/auth";
export function GeneralSettings() {
const settings = useGetSettings();
@@ -137,6 +138,10 @@ export function GeneralSettings() {
+
+
+
+
{contextHolder}
@@ -144,6 +149,176 @@ export function GeneralSettings() {
);
}
+interface AuthSettingsProps {
+ messageApi: ReturnType[0];
+ t: ReturnType;
+}
+
+function AuthSettings({ messageApi, t }: AuthSettingsProps) {
+ const { authStatus, refreshAuthStatus, setup } = useAuth();
+ const [toggling, setToggling] = useState(false);
+ const [setupModalOpen, setSetupModalOpen] = useState(false);
+ const [disableModalOpen, setDisableModalOpen] = useState(false);
+ const [setupForm] = Form.useForm();
+ const [disableForm] = Form.useForm();
+
+ const handleToggle = async (enabled: boolean) => {
+ if (enabled) {
+ // If enabling and no users exist, show setup modal
+ if (!authStatus?.has_users) {
+ setSetupModalOpen(true);
+ return;
+ }
+ // If users exist, just enable
+ await toggleAuth(true);
+ } else {
+ // Disabling - show password confirmation modal
+ setDisableModalOpen(true);
+ }
+ };
+
+ const toggleAuth = async (enabled: boolean, password?: string) => {
+ setToggling(true);
+ try {
+ const res = await fetch(`${getAPIURL()}/auth/toggle`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled, password }),
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.detail || "Failed to toggle authentication");
+ }
+
+ await refreshAuthStatus();
+ messageApi.success(
+ enabled
+ ? t("settings.auth.enabled_success")
+ : t("settings.auth.disabled_success")
+ );
+
+ if (enabled) {
+ // Redirect to login after enabling
+ window.location.href = "/login";
+ }
+ } catch (err) {
+ messageApi.error(err instanceof Error ? err.message : "Failed to toggle authentication");
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ const handleSetup = async (values: { username: string; password: string }) => {
+ setToggling(true);
+ try {
+ // Create the first admin user
+ await setup(values.username, values.password);
+ // Enable auth
+ await toggleAuth(true);
+ setSetupModalOpen(false);
+ setupForm.resetFields();
+ } catch (err) {
+ messageApi.error(err instanceof Error ? err.message : "Setup failed");
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ const handleDisable = async (values: { password: string }) => {
+ await toggleAuth(false, values.password);
+ setDisableModalOpen(false);
+ disableForm.resetFields();
+ };
+
+ return (
+
+
+
+ {t("settings.auth.title")}
+
+
+ {t("settings.auth.description")}
+
+
+
+
+
+ {authStatus?.enabled
+ ? t("settings.auth.status_enabled")
+ : t("settings.auth.status_disabled")}
+
+
+
+ {/* Setup Modal - Create first admin user */}
+
{
+ setSetupModalOpen(false);
+ setupForm.resetFields();
+ }}
+ onOk={() => setupForm.submit()}
+ okText={t("settings.auth.enable_button")}
+ confirmLoading={toggling}
+ >
+ {t("settings.auth.setup_description")}
+
+
+
+
+
+
+
+
+
+ {/* Disable Modal - Require password confirmation */}
+
{
+ setDisableModalOpen(false);
+ disableForm.resetFields();
+ }}
+ onOk={() => disableForm.submit()}
+ okText={t("settings.auth.disable_button")}
+ okButtonProps={{ danger: true }}
+ confirmLoading={toggling}
+ >
+ {t("settings.auth.disable_description")}
+
+
+
+
+
+
+ );
+}
+
interface BackupRestoreProps {
messageApi: ReturnType[0];
t: ReturnType;
diff --git a/pyproject.toml b/pyproject.toml
index 8ad8111..de5a13b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "spoolman"
-version = "0.23C.4"
+version = "0.23C.5"
description = "A web service that keeps track of 3D printing spools."
authors = [
{ name = "Tony", email = "tony@nastynas.xyz" },
diff --git a/spoolman/api/v1/auth.py b/spoolman/api/v1/auth.py
index f4b76b9..3af2d63 100644
--- a/spoolman/api/v1/auth.py
+++ b/spoolman/api/v1/auth.py
@@ -1,5 +1,6 @@
"""Authentication API endpoints."""
+import json
import logging
from datetime import datetime
from typing import Annotated, Optional
@@ -22,9 +23,11 @@ from spoolman.auth import (
get_password_hash,
get_user_by_username,
is_auth_enabled,
+ set_auth_enabled_cache,
)
from spoolman.database import models
from spoolman.database.database import get_db_session
+from spoolman.settings import SETTINGS
logger = logging.getLogger(__name__)
@@ -287,3 +290,85 @@ async def delete_user(
logger.info("User deleted: %s", user.username)
return {"message": "User deleted"}
+
+
+class AuthToggleRequest(BaseModel):
+ """Request to enable or disable authentication."""
+
+ enabled: bool
+ password: Optional[str] = Field(None, description="Admin password required when disabling auth")
+
+
+@router.post(
+ "/toggle",
+ name="Toggle authentication",
+ description="Enable or disable authentication. Requires admin password to disable.",
+ response_model=AuthStatusResponse,
+)
+async def toggle_auth(
+ body: AuthToggleRequest,
+ db: Annotated[AsyncSession, Depends(get_db_session)],
+) -> AuthStatusResponse:
+ """Enable or disable authentication."""
+ # Get current user count
+ result = await db.execute(select(func.count()).select_from(models.User))
+ user_count = result.scalar() or 0
+
+ if body.enabled:
+ # Enabling auth
+ if user_count == 0:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Cannot enable auth without users. Use /auth/setup to create the first admin user.",
+ )
+ else:
+ # Disabling auth - require admin password for security
+ if not body.password:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Admin password required to disable authentication",
+ )
+
+ # Find any admin user and verify password
+ result = await db.execute(
+ select(models.User).where(models.User.role == Role.ADMIN.value, models.User.is_active == True) # noqa: E712
+ )
+ admin_users = result.scalars().all()
+
+ if not admin_users:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="No active admin user found",
+ )
+
+ # Check if password matches any admin
+ password_valid = False
+ for admin in admin_users:
+ if auth.verify_password(body.password, admin.hashed_password):
+ password_valid = True
+ break
+
+ if not password_valid:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid admin password",
+ )
+
+ # Update the setting in database
+ setting = models.Setting(
+ key="auth_enabled",
+ value=json.dumps(body.enabled),
+ last_updated=datetime.utcnow().replace(microsecond=0),
+ )
+ await db.merge(setting)
+ await db.commit()
+
+ # Update the cache
+ set_auth_enabled_cache(body.enabled)
+
+ logger.info("Authentication %s", "enabled" if body.enabled else "disabled")
+
+ return AuthStatusResponse(
+ enabled=body.enabled,
+ has_users=user_count > 0,
+ )
diff --git a/spoolman/auth.py b/spoolman/auth.py
index 04327d4..36e7ae9 100644
--- a/spoolman/auth.py
+++ b/spoolman/auth.py
@@ -1,5 +1,6 @@
"""Authentication utilities and dependencies."""
+import json
import logging
import os
import secrets
@@ -31,10 +32,35 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 scheme - auto_error=False allows unauthenticated access when auth is disabled
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login", auto_error=False)
+# Cached auth enabled state (loaded from database on startup)
+_auth_enabled_cache: bool = False
+
def is_auth_enabled() -> bool:
- """Check if authentication is enabled via environment variable."""
- return os.environ.get("SPOOLMAN_AUTH_ENABLED", "false").lower() in ("true", "1", "yes")
+ """Check if authentication is enabled (from cached database setting)."""
+ return _auth_enabled_cache
+
+
+def set_auth_enabled_cache(enabled: bool) -> None:
+ """Update the cached auth enabled state."""
+ global _auth_enabled_cache
+ _auth_enabled_cache = enabled
+ logger.info("Auth enabled cache updated: %s", enabled)
+
+
+async def load_auth_enabled_from_db(db: AsyncSession) -> bool:
+ """Load auth_enabled setting from database and update cache."""
+ from spoolman.settings import SETTINGS
+
+ setting = await db.get(models.Setting, "auth_enabled")
+ if setting is not None:
+ enabled = json.loads(setting.value)
+ else:
+ # Use default from settings definition
+ enabled = json.loads(SETTINGS["auth_enabled"].default)
+
+ set_auth_enabled_cache(enabled)
+ return enabled
class Role(str, Enum):
diff --git a/spoolman/main.py b/spoolman/main.py
index 69c3236..13ff96f 100644
--- a/spoolman/main.py
+++ b/spoolman/main.py
@@ -13,7 +13,7 @@ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from prometheus_client import generate_latest
from scheduler.asyncio.scheduler import Scheduler
-from spoolman import env, externaldb
+from spoolman import auth, env, externaldb
from spoolman.api.v1.router import app as v1_app
from spoolman.client import SinglePageApplication
from spoolman.database import database
@@ -173,6 +173,12 @@ async def startup() -> None:
project_root = Path(__file__).parent.parent
subprocess.run(["alembic", "upgrade", "head"], check=True, cwd=project_root) # noqa: S603, S607, ASYNC221
+ # Load auth enabled setting from database
+ logger.info("Loading auth settings...")
+ async for db in database.get_db_session():
+ auth_enabled = await auth.load_auth_enabled_from_db(db)
+ logger.info("Authentication enabled: %s", auth_enabled)
+
# Setup scheduler
schedule = Scheduler()
database.schedule_tasks(schedule)
diff --git a/spoolman/settings.py b/spoolman/settings.py
index 0c6ce31..c8530e7 100644
--- a/spoolman/settings.py
+++ b/spoolman/settings.py
@@ -72,3 +72,4 @@ register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))
+register_setting("auth_enabled", SettingType.BOOLEAN, json.dumps(False))