Patch: Multi-user and OIDC polish (#612)

- Moved backend OIDC functionality to external library Authlib to help
maintainability
- Separated User settings UI into individual components, allowing for
standard settings UI decorator components to be used.
- Added full support for reverse proxy and CWA users alongside local and
OIDC
- Added mapping and syncing functionality for OIDC, CWA and reverse
proxy users
- Added per-user settings into the app-wide config system. Each config
can be declared as user-overrideable, and app-wide functionality can now
receive user-specific options via standard config calls.
- Added per-user audiobook destination config
- Updated login modal UI for simplified login, plus custom labels for
OIDC login
- Added user visibility in header dropdown
- Unified "restrict settings to admin" to use app-wide user roles.
This commit is contained in:
Alex
2026-02-12 14:38:28 +00:00
committed by GitHub
parent 2d2f54729f
commit 5bed0b20f4
64 changed files with 5775 additions and 2816 deletions

View File

@@ -14,3 +14,4 @@ emoji
rarfile
qbittorrent-api
transmission-rpc
authlib>=1.6.6,<1.7

View File

@@ -0,0 +1,123 @@
"""Configuration migration helpers."""
import json
from typing import Any, Callable
_DEPRECATED_SETTINGS_RESTRICTION_KEYS = (
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
"CWA_RESTRICT_SETTINGS_TO_ADMIN",
"RESTRICT_SETTINGS_TO_ADMIN",
)
def _as_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _pick_legacy_settings_restriction(config: dict[str, Any]) -> bool | None:
"""Pick the best legacy admin-restriction value to migrate."""
auth_method = str(config.get("AUTH_METHOD", "")).strip().lower()
if (
auth_method == "proxy"
and "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config
):
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if auth_method == "cwa" and "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("RESTRICT_SETTINGS_TO_ADMIN"))
if "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN"))
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" in config:
return _as_bool(config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN"))
return None
def migrate_security_settings(
*,
load_security_config: Callable[[], dict[str, Any]],
load_users_config: Callable[[], dict[str, Any]],
save_users_config: Callable[[dict[str, Any]], None],
ensure_config_dir: Callable[[], None],
get_config_path: Callable[[], Any],
sync_builtin_admin_user: Callable[[str, str], None],
logger: Any,
) -> None:
"""Migrate legacy security keys and sync builtin admin credentials."""
try:
config = load_security_config()
users_config = load_users_config()
migrated_security = False
migrated_users = False
if "USE_CWA_AUTH" in config:
old_value = config.pop("USE_CWA_AUTH")
if "AUTH_METHOD" not in config:
if old_value:
config["AUTH_METHOD"] = "cwa"
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
else:
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
config["AUTH_METHOD"] = "builtin"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
else:
config["AUTH_METHOD"] = "none"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
migrated_security = True
else:
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
migrated_security = True
if "RESTRICT_SETTINGS_TO_ADMIN" not in users_config:
legacy_restrict = _pick_legacy_settings_restriction(config)
if legacy_restrict is not None:
save_users_config({"RESTRICT_SETTINGS_TO_ADMIN": legacy_restrict})
migrated_users = True
logger.info(
"Migrated legacy settings-admin restriction to users.RESTRICT_SETTINGS_TO_ADMIN="
f"{legacy_restrict}"
)
for deprecated_key in _DEPRECATED_SETTINGS_RESTRICTION_KEYS:
if deprecated_key in config:
config.pop(deprecated_key, None)
migrated_security = True
logger.info(f"Removed deprecated security setting: {deprecated_key}")
try:
sync_builtin_admin_user(
config.get("BUILTIN_USERNAME", ""),
config.get("BUILTIN_PASSWORD_HASH", ""),
)
except Exception as exc:
logger.error(
"Failed to sync builtin credentials to users database during migration: "
f"{exc}"
)
if migrated_security:
ensure_config_dir()
config_path = get_config_path()
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
logger.info("Security settings migration completed successfully")
elif migrated_users:
logger.info("Users settings migration completed successfully")
else:
logger.debug("No security settings migration needed")
except FileNotFoundError:
logger.debug("No existing security config file found - nothing to migrate")
except Exception as exc:
logger.error(f"Failed to migrate security settings: {exc}")

View File

@@ -1,9 +1,14 @@
"""Authentication settings registration."""
from typing import Any, Dict
from typing import Any, Dict, Callable
from werkzeug.security import generate_password_hash
from shelfmark.config.migrations import migrate_security_settings
from shelfmark.config.security_handlers import (
on_save_security,
test_oidc_connection,
)
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
register_settings,
@@ -16,200 +21,61 @@ from shelfmark.core.settings_registry import (
ActionButton,
TagListField,
)
from shelfmark.core.user_db import sync_builtin_admin_user
logger = setup_logger(__name__)
def _auth_condition(auth_method: str) -> dict[str, str]:
return {"field": "AUTH_METHOD", "value": auth_method}
def _ui_field(factory: Callable[..., Any], **kwargs: Any) -> Any:
return factory(env_supported=False, **kwargs)
def _auth_ui_field(factory: Callable[..., Any], auth_method: str, **kwargs: Any) -> Any:
return _ui_field(factory, show_when=_auth_condition(auth_method), **kwargs)
def _migrate_security_settings() -> None:
import json
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
from shelfmark.core.settings_registry import (
_get_config_file_path,
_ensure_config_dir,
save_config_file,
)
try:
config = load_config_file("security")
migrated = False
# Migrate USE_CWA_AUTH to AUTH_METHOD
if "USE_CWA_AUTH" in config:
old_value = config.pop("USE_CWA_AUTH")
# Only set AUTH_METHOD if it doesn't already exist
if "AUTH_METHOD" not in config:
if old_value:
config["AUTH_METHOD"] = "cwa"
logger.info("Migrated USE_CWA_AUTH=True to AUTH_METHOD='cwa'")
else:
# If USE_CWA_AUTH was False, determine auth method from credentials
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
config["AUTH_METHOD"] = "builtin"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='builtin'")
else:
config["AUTH_METHOD"] = "none"
logger.info("Migrated USE_CWA_AUTH=False to AUTH_METHOD='none'")
migrated = True
else:
logger.info("Removed deprecated USE_CWA_AUTH setting (AUTH_METHOD already exists)")
migrated = True
# Migrate RESTRICT_SETTINGS_TO_ADMIN to CWA_RESTRICT_SETTINGS_TO_ADMIN
if "RESTRICT_SETTINGS_TO_ADMIN" in config:
old_value = config.pop("RESTRICT_SETTINGS_TO_ADMIN")
# Only migrate if new key doesn't exist
if "CWA_RESTRICT_SETTINGS_TO_ADMIN" not in config:
config["CWA_RESTRICT_SETTINGS_TO_ADMIN"] = old_value
logger.info(f"Migrated RESTRICT_SETTINGS_TO_ADMIN={old_value} to CWA_RESTRICT_SETTINGS_TO_ADMIN={old_value}")
migrated = True
else:
logger.info("Removed deprecated RESTRICT_SETTINGS_TO_ADMIN setting (CWA_RESTRICT_SETTINGS_TO_ADMIN already exists)")
migrated = True
# Save config if any migrations occurred
if migrated:
_ensure_config_dir("security")
config_path = _get_config_file_path("security")
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
logger.info("Security settings migration completed successfully")
else:
logger.debug("No security settings migration needed")
except FileNotFoundError:
logger.debug("No existing security config file found - nothing to migrate")
except Exception as e:
logger.error(f"Failed to migrate security settings: {e}")
migrate_security_settings(
load_security_config=lambda: load_config_file("security"),
load_users_config=lambda: load_config_file("users"),
save_users_config=lambda values: save_config_file("users", values),
ensure_config_dir=lambda: _ensure_config_dir("security"),
get_config_path=lambda: _get_config_file_path("security"),
sync_builtin_admin_user=sync_builtin_admin_user,
logger=logger,
)
def _clear_builtin_credentials() -> Dict[str, Any]:
"""Clear built-in credentials to allow public access."""
import json
from shelfmark.core.settings_registry import _get_config_file_path, _ensure_config_dir
try:
config = load_config_file("security")
config.pop("BUILTIN_USERNAME", None)
config.pop("BUILTIN_PASSWORD_HASH", None)
_ensure_config_dir("security")
config_path = _get_config_file_path("security")
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
logger.info("Cleared credentials")
return {"success": True, "message": "Credentials cleared. The app is now publicly accessible."}
except Exception as e:
logger.error(f"Failed to clear credentials: {e}")
return {"success": False, "message": f"Failed to clear credentials: {str(e)}"}
def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
"""
Custom save handler for security settings.
Handles password validation and hashing:
- If new password is provided, validate confirmation and hash it
- If password fields are empty, preserve existing hash
- Never store raw passwords
- Ensure username is present if password is set
Returns:
Dict with processed values to save and any validation errors.
"""
# If switching to OIDC, ensure a local admin exists as fallback
if values.get("AUTH_METHOD") == "oidc":
import os
from shelfmark.core.user_db import UserDB
db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")
udb = UserDB(db_path)
udb.initialize()
users = udb.list_users()
has_local_admin = any(
u.get("password_hash") and u.get("role") == "admin"
for u in users
)
if not has_local_admin:
return {
"error": True,
"message": (
"Create a local admin account first (Users tab) before enabling OIDC. "
"This ensures you can still log in with a password if SSO is unavailable."
),
"values": values,
}
password = values.get("BUILTIN_PASSWORD", "")
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
# Remove raw password fields - they should never be persisted
values.pop("BUILTIN_PASSWORD", None)
values.pop("BUILTIN_PASSWORD_CONFIRM", None)
# If password is provided, validate and hash it
if password:
if not values.get("BUILTIN_USERNAME"):
return {
"error": True,
"message": "Username cannot be empty",
"values": values
}
if password != password_confirm:
return {
"error": True,
"message": "Passwords do not match",
"values": values
}
if len(password) < 4:
return {
"error": True,
"message": "Password must be at least 4 characters",
"values": values
}
# Hash the password
values["BUILTIN_PASSWORD_HASH"] = generate_password_hash(password)
logger.info("Password hash updated")
# If no password provided but username is being set, preserve existing hash
elif "BUILTIN_USERNAME" in values:
existing = load_config_file("security")
if "BUILTIN_PASSWORD_HASH" in existing:
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
return {"error": False, "values": values}
return on_save_security(
values,
load_security_config=lambda: load_config_file("security"),
hash_password=generate_password_hash,
sync_builtin_admin_user=sync_builtin_admin_user,
logger=logger,
)
def _test_oidc_connection() -> Dict[str, Any]:
"""Test OIDC connection by fetching the discovery document."""
import requests
try:
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
resp = requests.get(discovery_url, timeout=10)
resp.raise_for_status()
doc = resp.json()
# Validate required fields
required = ["issuer", "authorization_endpoint", "token_endpoint"]
missing = [f for f in required if f not in doc]
if missing:
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing)}"}
return {"success": True, "message": f"Connected to {doc['issuer']}"}
except Exception as e:
logger.error(f"OIDC connection test failed: {e}")
return {"success": False, "message": f"Connection failed: {str(e)}"}
return test_oidc_connection(
load_security_config=lambda: load_config_file("security"),
logger=logger,
)
@register_settings("security", "Security", icon="shield", order=5)
def security_settings():
def security_settings():
"""Security and authentication settings."""
from shelfmark.config.env import CWA_DB_PATH
@@ -217,7 +83,7 @@ def security_settings():
auth_method_options = [
{"label": "No Authentication", "value": "none"},
{"label": "Username/Password", "value": "builtin"},
{"label": "Local", "value": "builtin"},
{"label": "Proxy Authentication", "value": "proxy"},
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
]
@@ -237,197 +103,151 @@ def security_settings():
default="none",
env_supported=False,
),
TextField(
key="BUILTIN_USERNAME",
label="Username",
description="Set a username and password to require login. Leave both empty for public access.",
placeholder="Enter username",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "builtin"},
),
PasswordField(
key="BUILTIN_PASSWORD",
label="Set Password",
description="Fill in to set or change the password.",
placeholder="Enter new password",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "builtin"},
),
PasswordField(
key="BUILTIN_PASSWORD_CONFIRM",
label="Confirm Password",
placeholder="Confirm new password",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "builtin"},
),
ActionButton(
key="clear_credentials",
label="Clear Credentials",
description="Remove login requirement and make the app publicly accessible.",
style="danger",
callback=_clear_builtin_credentials,
show_when={"field": "AUTH_METHOD", "value": "builtin"},
key="open_users_tab",
label="Go to Users",
description="Configure local users and admin access in the Users tab.",
style="primary",
show_when=_auth_condition("builtin"),
),
TextField(
_auth_ui_field(
TextField,
"proxy",
key="PROXY_AUTH_USER_HEADER",
label="Proxy Auth User Header",
description=(
"The HTTP header your proxy uses to pass the authenticated username."
),
description="The HTTP header your proxy uses to pass the authenticated username.",
placeholder="e.g. X-Auth-User",
default="X-Auth-User",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "proxy"},
),
TextField(
_auth_ui_field(
TextField,
"proxy",
key="PROXY_AUTH_LOGOUT_URL",
label="Proxy Auth Logout URL",
description=(
"The URL to redirect users to for logging out."
" Leave empty to disable logout functionality."
),
description="The URL to redirect users to for logging out. Leave empty to disable logout functionality.",
placeholder="https://myauth.example.com/logout",
default="",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "proxy"},
),
CheckboxField(
key="PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings to Admins authenticated via Proxy",
description=(
"Only users in the admin group can access settings."
),
default=False,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "proxy"},
),
TextField(
_auth_ui_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_HEADER",
label="Proxy Auth Admin Group Header",
description=(
"The HTTP header your proxy uses to pass the user's groups/roles."
),
description="Optional: header your proxy uses to pass user groups/roles.",
placeholder="e.g. X-Auth-Groups",
default="X-Auth-Groups",
env_supported=False,
show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True},
),
TextField(
_auth_ui_field(
TextField,
"proxy",
key="PROXY_AUTH_ADMIN_GROUP_NAME",
label="Proxy Auth Admin Group Name",
description=(
"The name of the group/role that should have admin access."
),
label="Proxy Auth Admin Group",
description="Optional: users in this group are treated as admins. Leave blank to skip group-based admin detection.",
placeholder="e.g. admins",
default="admins",
env_supported=False,
show_when={"field": "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", "value": True},
),
CheckboxField(
key="CWA_RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings to Admins authenticated via Calibre-Web",
description=(
"Only users with admin role in Calibre-Web can access settings."
),
default=False,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "cwa"},
),
# === OIDC SETTINGS ===
TextField(
key="OIDC_DISCOVERY_URL",
label="Discovery URL",
description=(
"OpenID Connect discovery endpoint URL."
" Usually ends with /.well-known/openid-configuration."
),
placeholder="https://auth.example.com/.well-known/openid-configuration",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_CLIENT_ID",
label="Client ID",
description="OAuth2 client ID from your identity provider.",
placeholder="shelfmark",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
PasswordField(
key="OIDC_CLIENT_SECRET",
label="Client Secret",
description="OAuth2 client secret from your identity provider.",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TagListField(
key="OIDC_SCOPES",
label="Scopes",
description="OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
default=["openid", "email", "profile"],
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_GROUP_CLAIM",
label="Group Claim Name",
description=(
"The name of the claim in the ID token that contains user groups."
),
placeholder="groups",
default="groups",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_ADMIN_GROUP",
label="Admin Group Name",
description=(
"Users in this group will be given admin access (if enabled below). "
"Leave empty to use database roles only."
),
placeholder="shelfmark-admins",
default="",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
CheckboxField(
key="OIDC_USE_ADMIN_GROUP",
label="Use Admin Group for Authorization",
description=(
"When enabled, users in the Admin Group are granted admin access. "
"When disabled, admin access is determined solely by database roles."
),
default=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
]
oidc_specs = [
(
TextField,
{
"key": "OIDC_DISCOVERY_URL",
"label": "Discovery URL",
"description": "OpenID Connect discovery endpoint URL. Usually ends with /.well-known/openid-configuration.",
"placeholder": "https://auth.example.com/.well-known/openid-configuration",
"required": True,
},
),
CheckboxField(
key="OIDC_AUTO_PROVISION",
label="Auto-Provision Users",
description=(
"Automatically create a user account on first OIDC login."
" When disabled, users must be pre-created by an admin."
),
default=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
(
TextField,
{
"key": "OIDC_CLIENT_ID",
"label": "Client ID",
"description": "OAuth2 client ID from your identity provider.",
"placeholder": "shelfmark",
"required": True,
},
),
(
PasswordField,
{
"key": "OIDC_CLIENT_SECRET",
"label": "Client Secret",
"description": "OAuth2 client secret from your identity provider.",
"required": True,
},
),
(
TagListField,
{
"key": "OIDC_SCOPES",
"label": "Scopes",
"description": "OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
"default": ["openid", "email", "profile"],
},
),
(
TextField,
{
"key": "OIDC_GROUP_CLAIM",
"label": "Group Claim Name",
"description": "The name of the claim in the ID token that contains user groups.",
"placeholder": "groups",
"default": "groups",
},
),
(
TextField,
{
"key": "OIDC_ADMIN_GROUP",
"label": "Admin Group Name",
"description": "Users in this group will be given admin access (if enabled below). Leave empty to use database roles only.",
"placeholder": "shelfmark-admins",
"default": "",
},
),
(
CheckboxField,
{
"key": "OIDC_USE_ADMIN_GROUP",
"label": "Use Admin Group for Authorization",
"description": "When enabled, users in the Admin Group are granted admin access. When disabled, admin access is determined solely by database roles.",
"default": True,
},
),
(
CheckboxField,
{
"key": "OIDC_AUTO_PROVISION",
"label": "Auto-Provision Users",
"description": "Automatically create a user account on first OIDC login. When disabled, users must be pre-created by an admin.",
"default": True,
},
),
(
TextField,
{
"key": "OIDC_BUTTON_LABEL",
"label": "Login Button Label",
"description": "Custom label for the OIDC sign-in button on the login page.",
"placeholder": "Sign in with OIDC",
"default": "",
},
),
]
fields.extend(_auth_ui_field(factory, "oidc", **spec) for factory, spec in oidc_specs)
fields.append(
ActionButton(
key="test_oidc",
label="Test Connection",
description="Fetch the OIDC discovery document and validate configuration.",
style="primary",
callback=_test_oidc_connection,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
]
show_when=_auth_condition("oidc"),
)
)
return fields
# Register the on_save handler for this tab
register_on_save("security", _on_save_security)

View File

@@ -0,0 +1,87 @@
"""Operational handlers for security settings (save/actions)."""
import os
from typing import Any, Callable
from shelfmark.core.user_db import UserDB
_OIDC_LOCKOUT_MESSAGE = "Create a local admin account first (Users tab) before enabling OIDC. This ensures you can still log in with a password if SSO is unavailable."
def _has_local_password_admin() -> bool:
root = os.environ.get("CONFIG_DIR", "/config")
user_db = UserDB(os.path.join(root, "users.db"))
user_db.initialize()
return any(user.get("password_hash") and user.get("role") == "admin" for user in user_db.list_users())
def on_save_security(
values: dict[str, Any],
*,
load_security_config: Callable[[], dict[str, Any]],
hash_password: Callable[[str], str],
sync_builtin_admin_user: Callable[[str, str], None],
logger: Any,
) -> dict[str, Any]:
"""Validate/process security values before persistence."""
if values.get("AUTH_METHOD") == "oidc" and not _has_local_password_admin():
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": values}
password = values.pop("BUILTIN_PASSWORD", "")
password_confirm = values.pop("BUILTIN_PASSWORD_CONFIRM", "")
if password:
if not values.get("BUILTIN_USERNAME"):
return {"error": True, "message": "Username cannot be empty", "values": values}
if password != password_confirm:
return {"error": True, "message": "Passwords do not match", "values": values}
if len(password) < 4:
return {"error": True, "message": "Password must be at least 4 characters", "values": values}
values["BUILTIN_PASSWORD_HASH"] = hash_password(password)
logger.info("Password hash updated")
elif "BUILTIN_USERNAME" in values:
existing = load_security_config()
if "BUILTIN_PASSWORD_HASH" in existing:
values["BUILTIN_PASSWORD_HASH"] = existing["BUILTIN_PASSWORD_HASH"]
if values.get("AUTH_METHOD") == "builtin":
try:
sync_builtin_admin_user(
values.get("BUILTIN_USERNAME", ""),
values.get("BUILTIN_PASSWORD_HASH", ""),
)
except Exception as exc:
logger.error(f"Failed to sync builtin admin user: {exc}")
return {"error": True, "message": "Failed to create/update local admin user from builtin credentials", "values": values}
return {"error": False, "values": values}
def test_oidc_connection(
*,
load_security_config: Callable[[], dict[str, Any]],
logger: Any,
) -> dict[str, Any]:
"""Fetch and validate the configured OIDC discovery document."""
import requests
try:
discovery_url = load_security_config().get("OIDC_DISCOVERY_URL", "")
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
response = requests.get(discovery_url, timeout=10)
response.raise_for_status()
document = response.json()
required_fields = ["issuer", "authorization_endpoint", "token_endpoint"]
missing_fields = [field for field in required_fields if field not in document]
if missing_fields:
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing_fields)}"}
return {"success": True, "message": f"Connected to {document['issuer']}"}
except Exception as exc:
logger.error(f"OIDC connection test failed: {exc}")
return {"success": False, "message": f"Connection failed: {str(exc)}"}

View File

@@ -633,46 +633,20 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
parsed = parseaddr(addr or "")[1]
return bool(parsed) and "@" in parsed and parsed == addr
raw_recipients = effective.get("EMAIL_RECIPIENTS", [])
if not isinstance(raw_recipients, list):
return {"error": True, "message": "Email recipients must be a list", "values": values}
# Preferred model: single recipient for global default and per-user override.
raw_recipient = str(effective.get("EMAIL_RECIPIENT", "") or "").strip()
cleaned_recipients: list[dict[str, str]] = []
seen_nicknames: set[str] = set()
for entry in raw_recipients:
if not isinstance(entry, dict):
continue
nickname = str(entry.get("nickname", "") or "").strip()
email = str(entry.get("email", "") or "").strip()
# Allow incomplete rows in the UI by skipping them.
if not nickname or not email:
continue
nickname_key = nickname.lower()
if nickname_key in seen_nicknames:
return {
"error": True,
"message": f"Duplicate email recipient nickname: {nickname}",
"values": values,
}
seen_nicknames.add(nickname_key)
if not _is_plain_email_address(email):
return {
"error": True,
"message": f"Invalid email address for '{nickname}': {email}",
"values": values,
}
cleaned_recipients.append({"nickname": nickname, "email": email})
if not cleaned_recipients:
if not raw_recipient:
return {
"error": True,
"message": "At least one email recipient is required (Downloads -> Books -> Email).",
"message": "Email recipient is required (Downloads -> Books -> Email Recipient).",
"values": values,
}
if not _is_plain_email_address(raw_recipient):
return {
"error": True,
"message": "Email recipient must be a valid plain email address.",
"values": values,
}
@@ -748,8 +722,8 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
}
# Persist any normalization/coercion for fields that may have been edited this save.
if "EMAIL_RECIPIENTS" in values:
values["EMAIL_RECIPIENTS"] = cleaned_recipients
if "EMAIL_RECIPIENT" in values:
values["EMAIL_RECIPIENT"] = raw_recipient
if "EMAIL_SMTP_SECURITY" in values:
values["EMAIL_SMTP_SECURITY"] = security
if "EMAIL_SMTP_PORT" in values:
@@ -795,6 +769,7 @@ def download_settings():
},
],
default="folder",
user_overridable=True,
),
TextField(
key="DESTINATION",
@@ -803,6 +778,7 @@ def download_settings():
default="/books",
required=True,
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
user_overridable=True,
show_when={
"field": "BOOKS_OUTPUT_MODE",
"value": "folder",
@@ -904,6 +880,7 @@ def download_settings():
description="Booklore library to upload into.",
options=get_booklore_library_options,
required=True,
user_overridable=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
SelectField(
@@ -913,6 +890,7 @@ def download_settings():
options=get_booklore_path_options,
required=True,
filter_by_field="BOOKLORE_LIBRARY_ID",
user_overridable=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
ActionButton(
@@ -929,27 +907,12 @@ def download_settings():
description="Send books as email attachments via SMTP. Audiobooks always use folder mode.",
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
),
TableField(
key="EMAIL_RECIPIENTS",
label="Recipients",
columns=[
{
"key": "nickname",
"label": "Nickname",
"type": "text",
"placeholder": "eReader",
},
{
"key": "email",
"label": "Email",
"type": "text",
"placeholder": "device@example.com",
},
],
default=[],
add_label="Add Recipient",
empty_message="No recipients configured.",
env_supported=False,
TextField(
key="EMAIL_RECIPIENT",
label="Email Recipient",
description="Email address that should receive downloaded books when Output Mode is Email.",
placeholder="reader@example.com",
user_overridable=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"},
),
NumberField(
@@ -1054,8 +1017,9 @@ def download_settings():
TextField(
key="DESTINATION_AUDIOBOOK",
label="Destination",
description="Leave empty to use Books destination.",
description="Leave empty to use Books destination. Use {User} for per-user folders (e.g. /audiobooks/{User}).",
placeholder="/audiobooks",
user_overridable=True,
universal_only=True,
),
SelectField(

View File

@@ -6,6 +6,7 @@ that talks to /api/admin/users endpoints.
"""
from shelfmark.core.settings_registry import (
CheckboxField,
HeadingField,
register_settings,
)
@@ -16,8 +17,17 @@ def users_settings():
"""User management tab - rendered as a custom component on the frontend."""
return [
HeadingField(
key="users_heading",
title="User Accounts",
description="Manage user accounts for multi-user authentication.",
key="users_access_heading",
title="Options",
),
CheckboxField(
key="RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings and Onboarding to Admins",
description=(
"When enabled, only admin users can access Settings and Onboarding. "
"When disabled, any authenticated user can access them."
),
default=True,
env_supported=False,
),
]

View File

@@ -5,6 +5,9 @@ All endpoints require admin session.
"""
from functools import wraps
import os
import sqlite3
from typing import Any
from flask import Flask, jsonify, request, session
from werkzeug.security import generate_password_hash
@@ -13,26 +16,64 @@ from shelfmark.config.booklore_settings import (
get_booklore_library_options,
get_booklore_path_options,
)
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import (
register_admin_settings_routes,
validate_user_settings,
)
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
determine_auth_mode,
has_local_password_admin,
normalize_auth_source,
)
from shelfmark.core.cwa_user_sync import sync_cwa_users_from_rows
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_DOWNLOAD_DEFAULTS = {
"BOOKS_OUTPUT_MODE": "folder",
"DESTINATION": "/books",
"BOOKLORE_LIBRARY_ID": "",
"BOOKLORE_PATH_ID": "",
"EMAIL_RECIPIENTS": [],
}
def _get_user_edit_capabilities(
user: dict[str, Any],
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return backend-authored capability flags for the user edit form."""
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
if security_config is None and auth_source == AUTH_SOURCE_OIDC:
security_config = load_config_file("security")
oidc_use_admin_group = bool((security_config or {}).get("OIDC_USE_ADMIN_GROUP", True))
role_managed_by_oidc_group = auth_source == AUTH_SOURCE_OIDC and oidc_use_admin_group
can_edit_role = auth_source == AUTH_SOURCE_BUILTIN or (
auth_source == AUTH_SOURCE_OIDC and not role_managed_by_oidc_group
)
return {
"authSource": auth_source,
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
"canEditRole": can_edit_role,
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
}
def _get_auth_mode():
"""Get current auth mode from config."""
try:
config = load_config_file("security")
return config.get("AUTH_METHOD", "none")
return determine_auth_mode(
config,
CWA_DB_PATH,
has_local_admin=has_local_password_admin(),
)
except Exception:
return "none"
@@ -57,8 +98,67 @@ def _require_admin(f):
def _sanitize_user(user: dict) -> dict:
"""Remove sensitive fields from user dict before returning to client."""
user.pop("password_hash", None)
return user
sanitized = dict(user)
sanitized.pop("password_hash", None)
return sanitized
def _oidc_role_management_message(security_config: dict[str, Any]) -> str:
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
if admin_group:
return (
"Admin roles for OIDC users are managed by the "
f"'{admin_group}' group in your identity provider"
)
return (
"Disable 'Use Admin Group for Authorization' in security settings "
"to manage roles manually"
)
def _is_user_active(user: dict[str, Any], auth_method: str) -> bool:
"""Determine whether a user can authenticate in the current auth mode."""
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
if source == AUTH_SOURCE_BUILTIN:
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
return source == auth_method
def _serialize_user(
user: dict[str, Any],
auth_method: str,
security_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Sanitize and enrich a user payload for API responses."""
payload = _sanitize_user(user)
payload["auth_source"] = normalize_auth_source(
payload.get("auth_source"),
payload.get("oidc_subject"),
)
payload["is_active"] = _is_user_active(payload, auth_method)
payload["edit_capabilities"] = _get_user_edit_capabilities(
payload,
security_config=security_config,
)
return payload
def _sync_all_cwa_users(user_db: UserDB) -> dict[str, int]:
"""Sync all users from the Calibre-Web database into users.db."""
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
raise FileNotFoundError("Calibre-Web database is not available")
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
try:
cur = conn.cursor()
cur.execute("SELECT name, role, email FROM user")
rows = cur.fetchall()
finally:
conn.close()
return sync_cwa_users_from_rows(user_db, rows)
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
@@ -69,13 +169,19 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
def admin_list_users():
"""List all users."""
users = user_db.list_users()
return jsonify([_sanitize_user(u) for u in users])
auth_mode = _get_auth_mode()
security_config = load_config_file("security")
return jsonify([
_serialize_user(u, auth_mode, security_config=security_config)
for u in users
])
@app.route("/api/admin/users", methods=["POST"])
@_require_admin
def admin_create_user():
"""Create a new user with password authentication."""
data = request.get_json() or {}
auth_mode = _get_auth_mode()
username = (data.get("username") or "").strip()
password = data.get("password", "")
@@ -83,6 +189,15 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
display_name = (data.get("display_name") or "").strip() or None
role = data.get("role", "user")
if auth_mode in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA}:
return jsonify({
"error": "Local user creation is disabled in this authentication mode",
"message": (
"Users are provisioned by your external authentication source. "
"Switch to builtin or OIDC mode to create local users."
),
}), 400
if not username:
return jsonify({"error": "Username is required"}), 400
if not password or len(password) < 4:
@@ -105,12 +220,23 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
password_hash=password_hash,
email=email,
display_name=display_name,
auth_source=AUTH_SOURCE_BUILTIN,
role=role,
)
except ValueError:
return jsonify({"error": "Username already exists"}), 409
logger.info(f"Admin created user: {username} (role={role})")
return jsonify(_sanitize_user(user)), 201
logger.info(
"Shelfmark user created "
f"(source=manual_admin_create, created_by={session.get('user_id', 'unknown')}, "
f"username={username}, role={role}, auth_source={AUTH_SOURCE_BUILTIN})"
)
return jsonify(
_serialize_user(
user,
_get_auth_mode(),
security_config=load_config_file("security"),
)
), 201
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
@_require_admin
@@ -120,7 +246,11 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if not user:
return jsonify({"error": "User not found"}), 404
result = _sanitize_user(user)
result = _serialize_user(
user,
_get_auth_mode(),
security_config=load_config_file("security"),
)
result["settings"] = user_db.get_user_settings(user_id)
return jsonify(result)
@@ -133,10 +263,21 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
return jsonify({"error": "User not found"}), 404
data = request.get_json() or {}
security_config = load_config_file("security")
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
capabilities = _get_user_edit_capabilities(user, security_config=security_config)
# Handle optional password update
password = data.get("password", "")
if password:
if not capabilities["canSetPassword"]:
return jsonify({
"error": f"Cannot set password for {auth_source.upper()} users",
"message": "Password authentication is only available for local users.",
}), 400
if len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters"}), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
@@ -150,24 +291,45 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
# Prevent changing OIDC user role when group-based auth is enabled
if "role" in user_fields and user.get("oidc_subject") and user_fields["role"] != user.get("role"):
security_config = load_config_file("security")
use_admin_group = security_config.get("OIDC_USE_ADMIN_GROUP", True)
if use_admin_group:
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
msg = (
f"Admin roles for OIDC users are managed by the '{admin_group}' group in your identity provider"
if admin_group
else "Disable 'Use Admin Group for Authorization' in security settings to manage roles manually"
)
role_changed = "role" in user_fields and user_fields["role"] != user.get("role")
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
display_name_changed = (
"display_name" in user_fields
and user_fields["display_name"] != user.get("display_name")
)
if role_changed and not capabilities["canEditRole"]:
if auth_source == AUTH_SOURCE_OIDC:
return jsonify({
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
"message": msg,
"message": _oidc_role_management_message(security_config),
}), 400
return jsonify({
"error": f"Cannot change role for {auth_source.upper()} users",
"message": "Role is managed by the external authentication source.",
}), 400
if email_changed and not capabilities["canEditEmail"]:
if auth_source == AUTH_SOURCE_CWA:
return jsonify({
"error": "Cannot change email for CWA users",
"message": "Email is synced from Calibre-Web.",
}), 400
return jsonify({
"error": "Cannot change email for OIDC users",
"message": "Email is managed by your identity provider.",
}), 400
if display_name_changed and not capabilities["canEditDisplayName"]:
return jsonify({
"error": "Cannot change display name for OIDC users",
"message": "Display name is managed by your identity provider.",
}), 400
# Prevent demoting the last admin
if "role" in user_fields and user_fields["role"] != "admin":
if role_changed and user_fields["role"] != "admin":
if user.get("role") == "admin":
other_admins = [
u for u in user_db.list_users()
@@ -176,50 +338,80 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if not other_admins:
return jsonify({"error": "Cannot remove admin role from the last admin account"}), 400
# Avoid unnecessary writes for no-op field updates.
for field in ("role", "email", "display_name"):
if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field)
if user_fields:
user_db.update_user(user_id, **user_fields)
# Update per-user settings
if "settings" in data and isinstance(data["settings"], dict):
user_db.set_user_settings(user_id, data["settings"])
if "settings" in data:
if not isinstance(data["settings"], dict):
return jsonify({"error": "Settings must be an object"}), 400
validated_settings, validation_errors = validate_user_settings(data["settings"])
if validation_errors:
return jsonify({
"error": "Invalid settings payload",
"details": validation_errors,
}), 400
user_db.set_user_settings(user_id, validated_settings)
# Ensure runtime reads see updated per-user overrides immediately.
try:
from shelfmark.core.config import config as app_config
app_config.refresh()
except Exception:
pass
updated = user_db.get_user(user_id=user_id)
result = _sanitize_user(updated)
result = _serialize_user(
updated,
_get_auth_mode(),
security_config=security_config,
)
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"Admin updated user {user_id}")
return jsonify(result)
@app.route("/api/admin/download-defaults", methods=["GET"])
@app.route("/api/admin/users/sync-cwa", methods=["POST"])
@_require_admin
def admin_download_defaults():
"""Return global download settings relevant to per-user overrides."""
config = load_config_file("downloads")
keys = [
"BOOKS_OUTPUT_MODE",
"DESTINATION",
"BOOKLORE_LIBRARY_ID",
"BOOKLORE_PATH_ID",
"EMAIL_RECIPIENTS",
]
defaults = {k: config.get(k, _DOWNLOAD_DEFAULTS.get(k)) for k in keys}
def admin_sync_cwa_users():
"""Manually sync users from Calibre-Web into users.db."""
auth_mode = _get_auth_mode()
if auth_mode != AUTH_SOURCE_CWA:
return jsonify({
"error": "CWA sync is only available when CWA authentication is enabled",
}), 400
# Include OIDC settings for UI warnings (e.g., when admin tries to set OIDC user role)
security_config = load_config_file("security")
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
try:
summary = _sync_all_cwa_users(user_db)
except FileNotFoundError:
return jsonify({
"error": "Calibre-Web database is not available",
"message": "Verify app.db is mounted and readable at /auth/app.db.",
}), 503
except Exception as exc:
logger.error(f"Failed to sync CWA users: {exc}")
return jsonify({
"error": "Failed to sync users from Calibre-Web",
}), 500
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
@_require_admin
def admin_booklore_options():
"""Return available BookLore library and path options."""
message = (
f"Synced {summary['total']} CWA users "
f"({summary['created']} created, {summary['updated']} updated)."
)
logger.info(message)
return jsonify({
"libraries": get_booklore_library_options(),
"paths": get_booklore_path_options(),
"success": True,
"message": message,
**summary,
})
register_admin_settings_routes(app, user_db, _require_admin)
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
@_require_admin
def admin_delete_user(user_id):
@@ -232,6 +424,17 @@ def register_admin_routes(app: Flask, user_db: UserDB) -> None:
if not user:
return jsonify({"error": "User not found"}), 404
auth_mode = _get_auth_mode()
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
if auth_source in {AUTH_SOURCE_PROXY, AUTH_SOURCE_CWA} and auth_source == auth_mode:
return jsonify({
"error": f"Cannot delete active {auth_source.upper()} users",
"message": f"{auth_source.upper()} users are automatically re-provisioned on login.",
}), 400
# Prevent deleting the last local admin
if user.get("role") == "admin" and user.get("password_hash"):
local_admins = [

View File

@@ -0,0 +1,196 @@
"""Admin settings-introspection routes and settings validation helpers."""
from typing import Any, Callable
from flask import Flask, jsonify, request
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
def _get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = _get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def validate_user_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
settings_registry = _get_settings_registry()
field_map = settings_registry.get_settings_field_map()
overridable_map = settings_registry.get_user_overridable_fields()
valid: dict[str, Any] = {}
errors: list[str] = []
for key, value in settings.items():
if key not in field_map:
errors.append(f"Unknown setting: {key}")
elif key not in overridable_map:
errors.append(f"Setting not user-overridable: {key}")
else:
valid[key] = value
return valid, errors
def register_admin_settings_routes(
app: Flask,
user_db: UserDB,
require_admin: Callable[[Callable[..., Any]], Callable[..., Any]],
) -> None:
@app.route("/api/admin/download-defaults", methods=["GET"])
@require_admin
def admin_download_defaults():
config = load_config_file("downloads")
defaults = {
key: ("" if (value := config.get(key, field.default)) is None else value)
for key, field in _get_ordered_user_overridable_fields("downloads")
}
security_config = load_config_file("security")
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
@require_admin
def admin_booklore_options():
from shelfmark.core import admin_routes
return jsonify({
"libraries": admin_routes.get_booklore_library_options(),
"paths": admin_routes.get_booklore_path_options(),
})
@app.route("/api/admin/users/<int:user_id>/delivery-preferences", methods=["GET"])
@require_admin
def admin_get_delivery_preferences(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
from shelfmark.core import settings_registry
from shelfmark.core.config import config as app_config
ordered_fields = _get_ordered_user_overridable_fields("downloads")
if not ordered_fields:
return jsonify({"error": "Downloads settings tab not found"}), 500
download_config = load_config_file("downloads")
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, "downloads", include_value=False)
serialized["fromEnv"] = bool(field.env_supported and settings_registry.is_value_from_env(field))
fields_payload.append(serialized)
global_values[key] = app_config.get(key, field.default)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in download_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return jsonify({
"tab": "downloads",
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
})
@app.route("/api/admin/settings/overrides-summary", methods=["GET"])
@require_admin
def admin_settings_overrides_summary():
settings_registry = _get_settings_registry()
tab_name = (request.args.get("tab") or "downloads").strip()
if not settings_registry.get_settings_tab(tab_name):
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
overridable_keys = list(settings_registry.get_user_overridable_fields(tab_name=tab_name))
keys_payload: dict[str, dict[str, Any]] = {}
for user_record in user_db.list_users():
user_settings = user_db.get_user_settings(user_record["id"])
if not isinstance(user_settings, dict):
continue
for key in overridable_keys:
if key not in user_settings or user_settings[key] is None:
continue
entry = keys_payload.setdefault(key, {"count": 0, "users": []})
entry["users"].append({
"userId": user_record["id"],
"username": user_record["username"],
"value": user_settings[key],
})
for summary in keys_payload.values():
summary["count"] = len(summary["users"])
return jsonify({"tab": tab_name, "keys": keys_payload})
@app.route("/api/admin/users/<int:user_id>/effective-settings", methods=["GET"])
@require_admin
def admin_get_effective_settings(user_id):
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
from shelfmark.core.config import config as app_config
from shelfmark.core.settings_registry import is_value_from_env
field_map = _get_settings_registry().get_user_overridable_fields()
user_settings = user_db.get_user_settings(user_id)
tab_config_cache: dict[str, dict[str, Any]] = {}
effective: dict[str, dict[str, Any]] = {}
for key, (field, tab_name) in sorted(field_map.items()):
value = app_config.get(key, field.default, user_id=user_id)
source = "default"
if field.env_supported and is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
else:
tab_config = tab_config_cache.setdefault(tab_name, load_config_file(tab_name))
if key in tab_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
return jsonify(effective)

View File

@@ -0,0 +1,104 @@
"""Authentication mode, auth-source normalization, and admin access policy helpers."""
import os
from typing import Any, Mapping
AUTH_SOURCE_BUILTIN = "builtin"
AUTH_SOURCE_OIDC = "oidc"
AUTH_SOURCE_PROXY = "proxy"
AUTH_SOURCE_CWA = "cwa"
AUTH_SOURCES = (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
AUTH_SOURCE_CWA,
)
AUTH_SOURCE_SET = frozenset(AUTH_SOURCES)
def has_local_password_admin(user_db: Any | None = None) -> bool:
"""Return True when at least one local admin with a password exists."""
try:
db = user_db
if db is None:
from shelfmark.core.user_db import UserDB
config_root = os.environ.get("CONFIG_DIR", "/config")
db = UserDB(os.path.join(config_root, "users.db"))
db.initialize()
return any(
user.get("password_hash") and user.get("role") == "admin"
for user in db.list_users()
)
except Exception:
return False
def normalize_auth_source(
source: Any,
oidc_subject: Any = None,
) -> str:
"""Resolve a stable auth source value from persisted fields."""
normalized = str(source or "").strip().lower()
if normalized in AUTH_SOURCE_SET:
return normalized
if oidc_subject:
return AUTH_SOURCE_OIDC
return AUTH_SOURCE_BUILTIN
def determine_auth_mode(
security_config: Mapping[str, Any],
cwa_db_path: Any | None,
*,
has_local_admin: bool = True,
) -> str:
"""Determine active auth mode from security config and runtime prerequisites."""
auth_mode = security_config.get("AUTH_METHOD", "none")
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
return AUTH_SOURCE_CWA
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
return AUTH_SOURCE_BUILTIN
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
return AUTH_SOURCE_PROXY
if (
auth_mode == AUTH_SOURCE_OIDC
and has_local_admin
and security_config.get("OIDC_DISCOVERY_URL")
and security_config.get("OIDC_CLIENT_ID")
):
return AUTH_SOURCE_OIDC
return "none"
def is_settings_or_onboarding_path(path: str) -> bool:
"""Return True when request path targets protected admin settings routes."""
return path.startswith("/api/settings") or path.startswith("/api/onboarding")
def should_restrict_settings_to_admin(
users_config: Mapping[str, Any],
) -> bool:
"""Return whether settings/onboarding access is limited to admins."""
return bool(users_config.get("RESTRICT_SETTINGS_TO_ADMIN", True))
def get_auth_check_admin_status(
_auth_mode: str,
users_config: Mapping[str, Any],
session_data: Mapping[str, Any],
) -> bool:
"""Resolve /api/auth/check `is_admin` value for settings UI access control."""
if "user_id" not in session_data:
return False
if not should_restrict_settings_to_admin(users_config):
return True
return bool(session_data.get("is_admin", False))

View File

@@ -1,11 +1,14 @@
"""Configuration singleton with ENV > config file > default resolution."""
import os
import sqlite3
from threading import Lock
from typing import Any, Dict, Optional
# Import lazily to avoid circular imports
_registry_module = None
_env_module = None
_user_db_module = None
def _get_registry():
@@ -26,6 +29,15 @@ def _get_env():
return _env_module
def _get_user_db_module():
"""Lazy import of user DB module to avoid optional dependency loops."""
global _user_db_module
if _user_db_module is None:
from shelfmark.core.user_db import UserDB
_user_db_module = UserDB
return _user_db_module
class Config:
"""
Dynamic configuration singleton that provides live settings access.
@@ -51,6 +63,10 @@ class Config:
self._cache: Dict[str, Any] = {}
self._field_map: Dict[str, tuple] = {} # key -> (field, tab_name)
self._cache_lock = Lock()
self._user_settings_cache: Dict[int, Dict[str, Any]] = {}
self._user_settings_cache_lock = Lock()
self._user_db = None
self._user_db_load_attempted = False
self._initialized = True
self._loaded = False
@@ -111,19 +127,85 @@ class Config:
with self._cache_lock:
self._loaded = False
self._load_settings()
with self._user_settings_cache_lock:
self._user_settings_cache.clear()
self._user_db = None
self._user_db_load_attempted = False
def get(self, key: str, default: Any = None) -> Any:
def _get_user_db(self):
"""Get or initialize a UserDB handle if available."""
if self._user_db is not None:
return self._user_db
if self._user_db_load_attempted:
return None
self._user_db_load_attempted = True
try:
user_db_cls = _get_user_db_module()
db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")
user_db = user_db_cls(db_path)
user_db.initialize()
self._user_db = user_db
return self._user_db
except Exception:
# Multi-user support is optional; fall back to global config when unavailable.
return None
def _get_user_settings(self, user_id: int) -> Dict[str, Any]:
"""Get cached per-user settings from user DB."""
with self._user_settings_cache_lock:
if user_id in self._user_settings_cache:
return self._user_settings_cache[user_id]
user_db = self._get_user_db()
if user_db is None:
return {}
try:
settings = user_db.get_user_settings(user_id)
except (sqlite3.OperationalError, OSError, ValueError, TypeError):
return {}
if not isinstance(settings, dict):
settings = {}
with self._user_settings_cache_lock:
self._user_settings_cache[user_id] = settings
return settings
def _get_user_override(self, user_id: int, key: str) -> Any:
"""Get a user override for a specific key."""
user_settings = self._get_user_settings(user_id)
return user_settings.get(key)
def get(self, key: str, default: Any = None, user_id: Optional[int] = None) -> Any:
"""
Get a setting value by key.
Args:
key: The setting key (e.g., 'MAX_RETRY')
default: Default value if setting not found
user_id: Optional DB user ID for per-user setting overrides
Returns:
The setting value, or default if not found
"""
self._ensure_loaded()
if key in self._field_map:
field, _ = self._field_map[key]
registry = _get_registry()
# Deployment-level ENV values always win.
if field.env_supported and registry.is_value_from_env(field):
return self._cache.get(key, default)
# User overrides are only available for explicitly overridable fields.
if user_id is not None and getattr(field, "user_overridable", False):
user_value = self._get_user_override(user_id, key)
if user_value is not None:
return user_value
return self._cache.get(key, default)
def __getattr__(self, name: str) -> Any:

View File

@@ -0,0 +1,75 @@
"""Helpers for provisioning and syncing Calibre-Web users into users.db."""
from __future__ import annotations
from typing import Any, Iterable
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.user_db import UserDB
_CWA_ALIAS_SUFFIX = "__cwa"
def _normalize_email(value: Any) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def upsert_cwa_user(
user_db: UserDB,
cwa_username: str,
cwa_email: str | None,
role: str,
context: str | None = None,
) -> tuple[dict[str, Any], str]:
"""Create/update a CWA-backed user with collision-safe matching."""
normalized_email = _normalize_email(cwa_email)
collision_strategy = "alias" if normalized_email else "takeover"
user, action = upsert_external_user(
user_db,
auth_source="cwa",
username=cwa_username,
email=normalized_email,
role=role,
allow_email_link=True,
collision_strategy=collision_strategy,
alias_suffix=_CWA_ALIAS_SUFFIX,
context=context,
)
if user is None:
raise RuntimeError("Unexpected CWA user sync result: no user returned")
return user, action
def sync_cwa_users_from_rows(
user_db: UserDB,
rows: Iterable[tuple[Any, Any, Any]],
) -> dict[str, int]:
"""Sync CWA users from raw `(name, role_flags, email)` rows."""
created = 0
updated = 0
for username, role_flags, email in rows:
normalized_username = str(username or "").strip()
if not normalized_username:
continue
role = "admin" if (int(role_flags or 0) & 1) == 1 else "user"
_, action = upsert_cwa_user(
user_db,
cwa_username=normalized_username,
cwa_email=_normalize_email(email),
role=role,
context="cwa_manual_sync",
)
if action == "created":
created += 1
else:
updated += 1
return {
"created": created,
"updated": updated,
"total": created + updated,
}

View File

@@ -0,0 +1,283 @@
"""Shared external identity matching and provisioning helpers."""
from __future__ import annotations
import re
from typing import Any, Literal
from shelfmark.core.auth_modes import normalize_auth_source
from shelfmark.core.logger import setup_logger
from shelfmark.core.user_db import UserDB
UNSET = object()
CollisionStrategy = Literal["takeover", "suffix", "alias"]
MatchReason = Literal[
"subject_match",
"existing_source_username_match",
"unique_email_match",
]
logger = setup_logger(__name__)
def _normalize_username(value: Any) -> str:
return str(value or "").strip()
def _normalize_email(value: Any) -> str | None:
if value is None:
return None
email = str(value).strip()
return email or None
def _normalize_display_name(value: Any) -> str | None:
if value is None:
return None
name = str(value).strip()
return name or None
def _email_key(value: str | None) -> str:
return (value or "").strip().lower()
def _normalize_role(value: Any) -> str:
return "admin" if str(value or "").strip().lower() == "admin" else "user"
def _get_by_subject(user_db: UserDB, subject_field: str | None, subject: str | None) -> dict[str, Any] | None:
if not subject_field or not subject:
return None
if subject_field == "oidc_subject":
return user_db.get_user(oidc_subject=subject)
return None
def find_unique_user_by_email(user_db: UserDB, email: str | None) -> dict[str, Any] | None:
key = _email_key(_normalize_email(email))
if not key:
return None
matches = [u for u in user_db.list_users() if _email_key(u.get("email")) == key]
return matches[0] if len(matches) == 1 else None
def find_external_user_match(
user_db: UserDB,
*,
auth_source: str,
username: str,
email: str | None,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
) -> tuple[dict[str, Any] | None, MatchReason | None]:
"""Find an existing local user that should be linked to an external identity."""
normalized_username = _normalize_username(username)
normalized_email = _normalize_email(email)
by_subject = _get_by_subject(user_db, subject_field, subject)
if by_subject is not None:
return by_subject, "subject_match"
by_username = user_db.get_user(username=normalized_username)
if by_username and normalize_auth_source(
by_username.get("auth_source"),
by_username.get("oidc_subject"),
) == auth_source:
return by_username, "existing_source_username_match"
if allow_email_link:
return find_unique_user_by_email(user_db, normalized_email), "unique_email_match"
return None, None
def _build_updates(
*,
auth_source: str,
role: str,
sync_role: bool,
email: str | None | object,
display_name: str | None | object,
subject_field: str | None,
subject: str | None,
) -> dict[str, Any]:
updates: dict[str, Any] = {"auth_source": auth_source}
if sync_role:
updates["role"] = _normalize_role(role)
if email is not UNSET:
updates["email"] = _normalize_email(email)
if display_name is not UNSET:
updates["display_name"] = _normalize_display_name(display_name)
if subject_field == "oidc_subject" and subject:
updates["oidc_subject"] = subject
return updates
def _next_suffix_username(user_db: UserDB, base_username: str) -> str:
candidate = base_username
suffix = 1
while user_db.get_user(username=candidate):
candidate = f"{base_username}_{suffix}"
suffix += 1
return candidate
def _find_existing_alias_user(
user_db: UserDB,
*,
auth_source: str,
alias_base: str,
) -> dict[str, Any] | None:
pattern = re.compile(rf"^{re.escape(alias_base)}(?:_\d+)?$")
candidates = [
user for user in user_db.list_users()
if pattern.match(str(user.get("username") or ""))
and normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) == auth_source
]
if not candidates:
return None
return sorted(candidates, key=lambda user: int(user.get("id") or 0))[0]
def _resolve_create_username(
user_db: UserDB,
*,
auth_source: str,
requested_username: str,
strategy: CollisionStrategy,
alias_suffix: str,
) -> tuple[str | None, dict[str, Any] | None, str]:
existing = user_db.get_user(username=requested_username)
if not existing:
return requested_username, None, "new_username_available"
if strategy == "takeover":
return None, existing, "username_collision_takeover"
if strategy == "suffix":
return _next_suffix_username(user_db, requested_username), None, "username_collision_suffix"
alias_base = f"{requested_username}{alias_suffix}"
alias_existing = _find_existing_alias_user(
user_db,
auth_source=auth_source,
alias_base=alias_base,
)
if alias_existing is not None:
return None, alias_existing, "reuse_existing_alias"
return _next_suffix_username(user_db, alias_base), None, "username_collision_alias"
def upsert_external_user(
user_db: UserDB,
*,
auth_source: str,
username: str,
role: str,
email: str | None | object = UNSET,
display_name: str | None | object = UNSET,
subject_field: str | None = None,
subject: str | None = None,
allow_email_link: bool = False,
sync_role: bool = True,
allow_create: bool = True,
collision_strategy: CollisionStrategy = "takeover",
alias_suffix: str | None = None,
context: str | None = None,
) -> tuple[dict[str, Any] | None, str]:
"""Create/update a user from an external auth identity.
Returns `(user, action)` where action is one of:
- `"updated"`
- `"created"`
- `"not_found"` (when `allow_create=False` and no link target exists)
"""
normalized_username = _normalize_username(username)
if not normalized_username:
raise ValueError("External username is required")
normalized_email = _normalize_email(email) if email is not UNSET else None
normalized_display_name = (
_normalize_display_name(display_name) if display_name is not UNSET else None
)
normalized_role = _normalize_role(role)
matched, match_reason = find_external_user_match(
user_db,
auth_source=auth_source,
username=normalized_username,
email=normalized_email,
subject_field=subject_field,
subject=subject,
allow_email_link=allow_email_link,
)
updates = _build_updates(
auth_source=auth_source,
role=normalized_role,
sync_role=sync_role,
email=normalized_email if email is not UNSET else UNSET,
display_name=normalized_display_name if display_name is not UNSET else UNSET,
subject_field=subject_field,
subject=subject,
)
if matched is not None:
user_db.update_user(matched["id"], **updates)
mapped = user_db.get_user(user_id=matched["id"]) or matched
logger.info(
"External user mapped to existing Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={match_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
f"shelfmark_username={mapped['username']})"
)
return mapped, "updated"
if not allow_create:
logger.info(
"External user could not be mapped and creation is disabled "
f"(source={auth_source}, context={context or 'unspecified'}, "
f"external_username={normalized_username})"
)
return None, "not_found"
resolved_alias_suffix = alias_suffix or f"__{auth_source}"
create_username, takeover_target, create_reason = _resolve_create_username(
user_db,
auth_source=auth_source,
requested_username=normalized_username,
strategy=collision_strategy,
alias_suffix=resolved_alias_suffix,
)
if takeover_target is not None:
user_db.update_user(takeover_target["id"], **updates)
mapped = user_db.get_user(user_id=takeover_target["id"]) or takeover_target
logger.info(
"External user mapped to existing Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={mapped['id']}, "
f"shelfmark_username={mapped['username']})"
)
return mapped, "updated"
create_kwargs: dict[str, Any] = {
"username": create_username,
"auth_source": auth_source,
"role": normalized_role,
}
if email is not UNSET:
create_kwargs["email"] = normalized_email
if display_name is not UNSET:
create_kwargs["display_name"] = normalized_display_name
if subject_field == "oidc_subject" and subject:
create_kwargs["oidc_subject"] = subject
created = user_db.create_user(**create_kwargs)
logger.info(
"External user created Shelfmark user "
f"(source={auth_source}, context={context or 'unspecified'}, reason={create_reason}, "
f"external_username={normalized_username}, shelfmark_user_id={created['id']}, "
f"shelfmark_username={created['username']})"
)
return created, "created"

View File

@@ -6,12 +6,9 @@ Flask route handlers are registered separately in main.py.
from typing import Any, Dict, List, Optional
from shelfmark.core.logger import setup_logger
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]:
"""Extract group list from an ID token claim.
@@ -52,51 +49,32 @@ def provision_oidc_user(
db: UserDB,
user_info: Dict[str, Any],
is_admin: Optional[bool] = None,
) -> Dict[str, Any]:
allow_email_link: bool = False,
allow_create: bool = True,
) -> Optional[Dict[str, Any]]:
"""Create or update a user from OIDC claims.
If a user with the same oidc_subject exists, updates their info.
If the username is taken by a different user, appends a numeric suffix.
Matching and collision handling use the shared external user linker:
- OIDC subject first
- optionally unique email linking (when `allow_email_link=True`)
- username conflict resolution via numeric suffix.
Admin role is synced from IdP when group-based auth is enabled (is_admin is not None):
- is_admin=True → DB role = admin
- is_admin=False → DB role = user (downgrade if needed)
When group-based auth is disabled (is_admin=None), preserve existing DB role.
The database is always the single source of truth for auth checks.
Returns None when no existing user is matchable and `allow_create=False`.
"""
oidc_subject = user_info["oidc_subject"]
# Check if user already exists by OIDC subject
existing = db.get_user(oidc_subject=oidc_subject)
if existing:
updates: Dict[str, Any] = {
"email": user_info.get("email"),
"display_name": user_info.get("display_name"),
}
# Sync role from IdP when group-based auth is enabled
if is_admin is not None:
updates["role"] = "admin" if is_admin else "user"
db.update_user(existing["id"], **updates)
return db.get_user(user_id=existing["id"])
role = "admin" if is_admin else "user"
# New user — resolve username conflicts
username = user_info["username"] or oidc_subject
if db.get_user(username=username):
# Username taken, append suffix
suffix = 1
while db.get_user(username=f"{username}_{suffix}"):
suffix += 1
username = f"{username}_{suffix}"
user = db.create_user(
username=username,
user, _ = upsert_external_user(
db,
auth_source="oidc",
username=user_info["username"] or oidc_subject,
role="admin" if is_admin else "user",
email=user_info.get("email"),
display_name=user_info.get("display_name"),
oidc_subject=oidc_subject,
role=role,
subject_field="oidc_subject",
subject=oidc_subject,
allow_email_link=allow_email_link,
sync_role=is_admin is not None,
allow_create=allow_create,
collision_strategy="suffix",
context="oidc_login",
)
logger.info(f"Provisioned OIDC user: {username} (sub={oidc_subject})")
return user

View File

@@ -1,16 +1,13 @@
"""OIDC Flask route handlers.
"""OIDC Flask route handlers using Authlib.
Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
Separated from main.py to keep the OIDC logic self-contained.
Business logic remains in oidc_auth.py.
"""
import hashlib
import secrets
import base64
from urllib.parse import urlencode
from typing import Any
import requests as http_requests
from flask import Flask, redirect, request, session, jsonify
from authlib.integrations.flask_client import OAuth
from flask import Flask, jsonify, redirect, request, session
from shelfmark.core.logger import setup_logger
from shelfmark.core.oidc_auth import (
@@ -22,249 +19,156 @@ from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
# Cache discovery document in memory (refreshed on restart)
_discovery_cache = {}
oauth = OAuth()
def _fetch_discovery(discovery_url: str) -> dict:
"""Fetch and cache the OIDC discovery document."""
if discovery_url in _discovery_cache:
return _discovery_cache[discovery_url]
resp = http_requests.get(discovery_url, timeout=10)
resp.raise_for_status()
doc = resp.json()
_discovery_cache[discovery_url] = doc
return doc
def _normalize_claims(raw_claims: Any) -> dict[str, Any]:
"""Return a plain dict for claims from Authlib token/userinfo payloads."""
if raw_claims is None:
return {}
if isinstance(raw_claims, dict):
return raw_claims
if hasattr(raw_claims, "to_dict"):
return raw_claims.to_dict() # type: ignore[no-any-return]
try:
return dict(raw_claims)
except Exception:
return {}
def _generate_pkce():
"""Generate PKCE code_verifier and code_challenge."""
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
def _is_email_verified(claims: dict[str, Any]) -> bool:
"""Normalize provider-specific email_verified values into a strict boolean."""
value = claims.get("email_verified", False)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() == "true"
return False
def _exchange_code(
token_endpoint: str,
code: str,
code_verifier: str,
client_id: str,
client_secret: str,
redirect_uri: str,
userinfo_endpoint: str | None = None,
) -> dict:
"""Exchange authorization code for tokens and return ID token claims.
def _get_oidc_client() -> tuple[Any, dict[str, Any]]:
"""Register and return an OIDC client from the current security config."""
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
If id_token is missing from the response, falls back to calling the userinfo endpoint.
"""
resp = http_requests.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
},
timeout=10,
)
resp.raise_for_status()
token_data = resp.json()
if not discovery_url or not client_id:
raise ValueError("OIDC not configured")
# Decode ID token (we trust the IdP since we just exchanged the code over TLS)
import json as json_mod
id_token_raw = token_data.get("id_token", "")
if id_token_raw:
# Decode JWT payload without verification (already validated by TLS + code exchange)
payload = id_token_raw.split(".")[1]
# Add required Base64 padding (0-3 '=' characters)
payload += "=" * ((-len(payload)) % 4)
claims = json_mod.loads(base64.urlsafe_b64decode(payload))
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
if isinstance(configured_scopes, list):
scope_values = [str(scope).strip() for scope in configured_scopes if str(scope).strip()]
elif isinstance(configured_scopes, str):
delimiter = "," if "," in configured_scopes else " "
scope_values = [scope.strip() for scope in configured_scopes.split(delimiter) if scope.strip()]
else:
# No ID token in response — try userinfo endpoint
access_token = token_data.get("access_token")
if userinfo_endpoint and access_token:
try:
logger.info("ID token not found in token response, fetching from userinfo endpoint")
userinfo_resp = http_requests.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
userinfo_resp.raise_for_status()
claims = userinfo_resp.json()
except http_requests.RequestException as e:
logger.error(f"Failed to fetch userinfo: {e}")
raise ValueError("OIDC authentication failed: missing id_token and userinfo endpoint unavailable")
else:
logger.error("OIDC token response missing both id_token and access_token")
raise ValueError("OIDC authentication failed: invalid token response")
scope_values = []
return claims
scopes = list(dict.fromkeys(["openid"] + scope_values))
admin_group = config.get("OIDC_ADMIN_GROUP", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
oauth._clients.pop("shelfmark_idp", None)
oauth.register(
name="shelfmark_idp",
client_id=client_id,
client_secret=config.get("OIDC_CLIENT_SECRET", ""),
server_metadata_url=discovery_url,
client_kwargs={
"scope": " ".join(scopes),
"code_challenge_method": "S256",
},
overwrite=True,
)
client = oauth.create_client("shelfmark_idp")
if client is None:
raise RuntimeError("OIDC client initialization failed")
return client, config
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
"""Register OIDC authentication routes on the Flask app."""
oauth.init_app(app)
@app.route("/api/auth/oidc/login", methods=["GET"])
def oidc_login():
"""Initiate OIDC login flow. Redirects to IdP."""
"""Initiate OIDC login flow and redirect to the provider."""
try:
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
# Build scopes from config (user-editable) with openid guaranteed
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
scopes = list(dict.fromkeys(["openid"] + configured_scopes)) # dedupe, openid first
admin_group = config.get("OIDC_ADMIN_GROUP", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
# Add group claim to scopes when using admin group authorization
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
if not discovery_url or not client_id:
return jsonify({"error": "OIDC not configured"}), 500
discovery = _fetch_discovery(discovery_url)
auth_endpoint = discovery["authorization_endpoint"]
# Generate PKCE and state
code_verifier, code_challenge = _generate_pkce()
state = secrets.token_urlsafe(32)
# Store in session for callback validation
session["oidc_state"] = state
session["oidc_code_verifier"] = code_verifier
# Build callback URL
client, _ = _get_oidc_client()
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
params = {
"client_id": client_id,
"response_type": "code",
"scope": " ".join(scopes),
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return redirect(f"{auth_endpoint}?{urlencode(params)}")
return client.authorize_redirect(redirect_uri)
except ValueError:
return jsonify({"error": "OIDC not configured"}), 500
except Exception as e:
logger.error(f"OIDC login error: {e}")
return jsonify({"error": "OIDC login failed"}), 500
@app.route("/api/auth/oidc/callback", methods=["GET"])
def oidc_callback():
"""Handle OIDC callback from IdP."""
"""Handle OIDC callback from identity provider."""
try:
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
logger.warning(f"OIDC callback error from IdP: {error}")
return jsonify({"error": "Authentication failed"}), 400
# Validate state
expected_state = session.get("oidc_state")
code_verifier = session.get("oidc_code_verifier")
client, config = _get_oidc_client()
token = client.authorize_access_token()
claims = _normalize_claims(token.get("userinfo"))
if not expected_state or not code_verifier:
return jsonify({"error": "Session expired. Please try logging in again."}), 400
# If userinfo isn't present in token payload, request it explicitly.
if not claims:
try:
claims = _normalize_claims(client.userinfo(token=token))
except TypeError:
claims = _normalize_claims(client.userinfo())
except Exception as e:
logger.error(f"Failed to fetch OIDC userinfo: {e}")
if not state or state != expected_state:
return jsonify({"error": "Invalid state parameter"}), 400
if not claims:
raise ValueError("OIDC authentication failed: missing user claims")
if not code:
return jsonify({"error": "Missing authorization code"}), 400
# Load config
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
client_secret = config.get("OIDC_CLIENT_SECRET", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
admin_group = config.get("OIDC_ADMIN_GROUP", "")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
auto_provision = config.get("OIDC_AUTO_PROVISION", True)
discovery = _fetch_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
userinfo_endpoint = discovery.get("userinfo_endpoint")
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
# Exchange code for tokens
claims = _exchange_code(
token_endpoint=token_endpoint,
code=code,
code_verifier=code_verifier,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
userinfo_endpoint=userinfo_endpoint,
)
# Extract user info and check groups
user_info = extract_user_info(claims)
groups = parse_group_claims(claims, group_claim)
# Determine admin status from group membership (if enabled)
is_admin = None
if admin_group and use_admin_group:
is_admin = admin_group in groups
# Check if user exists by OIDC subject first
existing_user = user_db.get_user(oidc_subject=user_info["oidc_subject"])
# If no match by subject, try email linking (for pre-created users)
# Only link when the IdP has verified the email to prevent privilege escalation
email_verified = claims.get("email_verified", False)
if not existing_user and user_info.get("email") and email_verified:
matching_users = [
u for u in user_db.list_users()
if u.get("email") and u["email"].lower() == user_info["email"].lower()
]
if len(matching_users) == 1:
existing_user = matching_users[0]
# Link OIDC subject to existing user
user_db.update_user(existing_user["id"], oidc_subject=user_info["oidc_subject"])
logger.info(f"Linked OIDC subject {user_info['oidc_subject']} to existing user {existing_user['username']}")
elif len(matching_users) > 1:
logger.warning(f"OIDC email linking skipped: multiple local accounts match email {user_info['email']}")
if not existing_user and not auto_provision:
logger.warning(f"OIDC login rejected: auto-provision disabled for {user_info['username']}")
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
user = provision_oidc_user(
user_db,
user_info,
is_admin=is_admin,
allow_email_link=allow_email_link,
allow_create=bool(auto_provision),
)
if user is None:
logger.warning(
f"OIDC login rejected: auto-provision disabled for {user_info['username']}"
)
return jsonify({"error": "Account not found. Contact your administrator."}), 403
# Provision or update user (database role is synced from group if enabled)
user = provision_oidc_user(user_db, user_info, is_admin=is_admin)
# Set session - database role is the single source of truth
session["user_id"] = user["username"]
session["is_admin"] = user.get("role") == "admin"
session["db_user_id"] = user["id"]
session.permanent = True
# Clean up OIDC session data
session.pop("oidc_state", None)
session.pop("oidc_code_verifier", None)
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
# Redirect to frontend (respect subpath deployments)
return redirect(request.script_root or "/")
except ValueError as e:
# Specific errors from _exchange_code (e.g., missing id_token and userinfo unavailable)
logger.error(f"OIDC callback error: {e}")
return jsonify({"error": str(e)}), 400
except Exception as e:

View File

@@ -22,6 +22,7 @@ class FieldBase:
required: bool = False # Whether field must have a value
env_var: Optional[str] = None # Override env var name (defaults to key)
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
user_overridable: bool = False # Whether admins can set per-user overrides for this field
disabled: bool = False # Whether field is disabled/greyed out
disabled_reason: str = "" # Explanation shown when disabled
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
@@ -259,6 +260,42 @@ def get_all_settings_tabs() -> List[SettingsTab]:
return sorted(_SETTINGS_REGISTRY.values(), key=lambda t: (t.order, t.name))
def _iter_value_fields(tab: SettingsTab):
"""Yield value-bearing fields for a tab."""
for field in tab.fields:
if isinstance(field, (ActionButton, HeadingField)):
continue
yield field
def get_settings_field_map(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
"""Return key -> (field, tab_name) map for value-bearing settings fields."""
tabs: List[SettingsTab]
if tab_name:
tab = get_settings_tab(tab_name)
if not tab:
return {}
tabs = [tab]
else:
tabs = get_all_settings_tabs()
field_map: Dict[str, tuple[SettingsField, str]] = {}
for tab in tabs:
for field in _iter_value_fields(tab):
field_map[field.key] = (field, tab.name)
return field_map
def get_user_overridable_fields(tab_name: Optional[str] = None) -> Dict[str, tuple[SettingsField, str]]:
"""Return key -> (field, tab_name) map for fields marked user_overridable."""
field_map = get_settings_field_map(tab_name=tab_name)
return {
key: (field, tab)
for key, (field, tab) in field_map.items()
if getattr(field, "user_overridable", False)
}
def list_registered_settings() -> List[str]:
"""List all registered settings tab names."""
return list(_SETTINGS_REGISTRY.keys())
@@ -685,6 +722,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
"disabled": getattr(field, 'disabled', False),
"disabledReason": getattr(field, 'disabled_reason', ''),
"requiresRestart": getattr(field, 'requires_restart', False),
"userOverridable": getattr(field, 'user_overridable', False),
}
# Add optional properties if set

View File

@@ -1,10 +1,12 @@
"""SQLite user database for multi-user support."""
import json
import os
import sqlite3
import threading
from typing import Any, Dict, List, Optional
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -17,6 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT,
password_hash TEXT,
oidc_subject TEXT UNIQUE,
auth_source TEXT NOT NULL DEFAULT 'builtin',
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -28,9 +31,54 @@ CREATE TABLE IF NOT EXISTS user_settings (
"""
def get_users_db_path(config_dir: Optional[str] = None) -> str:
"""Return the configured users database path."""
root = config_dir or os.environ.get("CONFIG_DIR", "/config")
return os.path.join(root, "users.db")
def sync_builtin_admin_user(
username: str,
password_hash: str,
db_path: Optional[str] = None,
) -> None:
"""Ensure a local admin user exists for configured builtin credentials."""
normalized_username = (username or "").strip()
normalized_hash = password_hash or ""
if not normalized_username or not normalized_hash:
return
user_db = UserDB(db_path or get_users_db_path())
user_db.initialize()
existing = user_db.get_user(username=normalized_username)
if existing:
updates: dict[str, Any] = {}
if existing.get("password_hash") != normalized_hash:
updates["password_hash"] = normalized_hash
if existing.get("role") != "admin":
updates["role"] = "admin"
if existing.get("auth_source") != AUTH_SOURCE_BUILTIN:
updates["auth_source"] = AUTH_SOURCE_BUILTIN
if updates:
user_db.update_user(existing["id"], **updates)
logger.info(f"Updated local admin user '{normalized_username}' from builtin settings")
return
user_db.create_user(
username=normalized_username,
password_hash=normalized_hash,
auth_source=AUTH_SOURCE_BUILTIN,
role="admin",
)
logger.info(f"Created local admin user '{normalized_username}' from builtin settings")
class UserDB:
"""Thread-safe SQLite user database."""
_VALID_AUTH_SOURCES = set(AUTH_SOURCE_SET)
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
@@ -47,12 +95,33 @@ class UserDB:
conn = self._connect()
try:
conn.executescript(_CREATE_TABLES_SQL)
conn.execute("PRAGMA journal_mode=WAL")
self._migrate_auth_source_column(conn)
conn.commit()
# WAL mode must be changed outside an open transaction.
conn.execute("PRAGMA journal_mode=WAL")
finally:
conn.close()
logger.info(f"User database initialized at {self._db_path}")
def _migrate_auth_source_column(self, conn: sqlite3.Connection) -> None:
"""Ensure users.auth_source exists and backfill historical rows."""
columns = conn.execute("PRAGMA table_info(users)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "auth_source" not in column_names:
conn.execute(
"ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'"
)
# Backfill OIDC-origin users created before auth_source existed.
conn.execute(
"UPDATE users SET auth_source = 'oidc' WHERE oidc_subject IS NOT NULL"
)
# Defensive cleanup for any legacy null/blank values.
conn.execute(
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
)
def create_user(
self,
username: str,
@@ -60,16 +129,29 @@ class UserDB:
display_name: Optional[str] = None,
password_hash: Optional[str] = None,
oidc_subject: Optional[str] = None,
auth_source: str = "builtin",
role: str = "user",
) -> Dict[str, Any]:
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
if auth_source not in self._VALID_AUTH_SOURCES:
raise ValueError(f"Invalid auth_source: {auth_source}")
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""INSERT INTO users (username, email, display_name, password_hash, oidc_subject, role)
VALUES (?, ?, ?, ?, ?, ?)""",
(username, email, display_name, password_hash, oidc_subject, role),
"""INSERT INTO users (
username, email, display_name, password_hash, oidc_subject, auth_source, role
)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
username,
email,
display_name,
password_hash,
oidc_subject,
auth_source,
role,
),
)
conn.commit()
user_id = cursor.lastrowid
@@ -108,7 +190,14 @@ class UserDB:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
_ALLOWED_UPDATE_COLUMNS = {"email", "display_name", "password_hash", "oidc_subject", "role"}
_ALLOWED_UPDATE_COLUMNS = {
"email",
"display_name",
"password_hash",
"oidc_subject",
"auth_source",
"role",
}
def update_user(self, user_id: int, **kwargs) -> None:
"""Update user fields. Raises ValueError if user not found or invalid column."""
@@ -117,6 +206,8 @@ class UserDB:
for k in kwargs:
if k not in self._ALLOWED_UPDATE_COLUMNS:
raise ValueError(f"Invalid column: {k}")
if "auth_source" in kwargs and kwargs["auth_source"] not in self._VALID_AUTH_SOURCES:
raise ValueError(f"Invalid auth_source: {kwargs['auth_source']}")
with self._lock:
conn = self._connect()
try:

View File

@@ -1,6 +1,8 @@
"""Shared utility functions for the Shelfmark."""
import base64
import os
import re
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
@@ -118,21 +120,88 @@ _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
"other": "INGEST_DIR_OTHER",
}
_USER_PLACEHOLDER_PATTERN = re.compile(r"\{user\}", re.IGNORECASE)
_INVALID_USER_PATH_CHARS = re.compile(r'[\\/:*?"<>|]')
def get_destination(is_audiobook: bool = False) -> Path:
def _sanitize_user_for_path(username: str) -> str:
"""Sanitize username for path usage in destination placeholders."""
sanitized = _INVALID_USER_PATH_CHARS.sub("_", username.strip())
return sanitized.strip(" .")
def _resolve_destination_username(
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> str:
explicit = str(username or "").strip()
if explicit:
return explicit
if user_id is None:
return ""
try:
from shelfmark.core.user_db import UserDB
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
user_db.initialize()
user = user_db.get_user(user_id=user_id)
if not user:
return ""
return str(user.get("username") or "").strip()
except Exception:
return ""
def _expand_user_destination_placeholder(
path_value: str,
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> str:
"""Expand `{User}` placeholders in destination paths."""
if not isinstance(path_value, str):
return path_value
if not _USER_PLACEHOLDER_PATTERN.search(path_value):
return path_value
resolved_username = _sanitize_user_for_path(
_resolve_destination_username(user_id=user_id, username=username)
)
return _USER_PLACEHOLDER_PATTERN.sub(resolved_username, path_value)
def get_destination(
is_audiobook: bool = False,
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> Path:
"""Get base destination directory. Audiobooks fall back to main destination."""
from shelfmark.core.config import config
if is_audiobook:
# Audiobook destination with fallback to main destination
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "", user_id=user_id)
if audiobook_dest:
return Path(audiobook_dest)
return Path(
_expand_user_destination_placeholder(
str(audiobook_dest),
user_id=user_id,
username=username,
)
)
# Main destination (also fallback for audiobooks)
# Check new setting first, then legacy INGEST_DIR
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/books")
return Path(destination)
destination = config.get("DESTINATION", "", user_id=user_id) or config.get("INGEST_DIR", "/books")
return Path(
_expand_user_destination_placeholder(
str(destination),
user_id=user_id,
username=username,
)
)
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:

View File

@@ -9,6 +9,7 @@ import random
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
from email.utils import parseaddr
from pathlib import Path
from threading import Event, Lock
from typing import Any, Dict, List, Optional, Tuple
@@ -75,63 +76,34 @@ def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
logger.error_trace(f"Error getting book info: {e}")
raise
def _normalize_email_recipients(value: Any) -> List[Dict[str, str]]:
"""Normalize EMAIL_RECIPIENTS config into a list of {nickname,email} dicts."""
if not isinstance(value, list):
return []
recipients: List[Dict[str, str]] = []
for entry in value:
if not isinstance(entry, dict):
continue
nickname = str(entry.get("nickname", "") or "").strip()
email = str(entry.get("email", "") or "").strip()
if not nickname or not email:
continue
recipients.append({"nickname": nickname, "email": email})
return recipients
def _is_plain_email_address(value: str) -> bool:
parsed = parseaddr(value or "")[1]
return bool(parsed) and "@" in parsed and parsed == value
def _resolve_email_recipient(
nickname: Optional[str],
user_recipients: Optional[List[Dict[str, str]]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a configured email recipient nickname to an email address.
Checks user-specific recipients first, then global config.
def _resolve_email_destination(
user_id: Optional[int] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Resolve the destination email address for email output mode.
Returns:
(email_to, label, error_message)
(email_to, error_message)
"""
configured_recipient = str(config.get("EMAIL_RECIPIENT", "", user_id=user_id) or "").strip()
if configured_recipient:
if _is_plain_email_address(configured_recipient):
return configured_recipient, None
return None, "Configured email recipient is invalid"
label = (nickname or "").strip()
if not label:
return None, None, None
# Check per-user recipients first
if user_recipients:
for entry in _normalize_email_recipients(user_recipients):
if entry["nickname"].strip().lower() == label.lower():
return entry["email"], entry["nickname"], None
# Fall back to global recipients
recipients = _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", []))
for entry in recipients:
if entry["nickname"].strip().lower() == label.lower():
return entry["email"], entry["nickname"], None
return None, label, f"Unknown email recipient: {label}"
return None, "Email recipient is required"
def queue_book(
book_id: str,
priority: int = 0,
source: str = "direct_download",
email_recipient: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
user_overrides: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Optional[str]]:
"""Add a book to the download queue. Returns (success, error_message)."""
try:
@@ -141,37 +113,23 @@ def queue_book(
logger.warning(error_msg)
return False, error_msg
books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower()
books_output_mode = str(
config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder"
).strip().lower()
is_audiobook = check_audiobook(book_info.content)
# Capture output mode at queue time so tasks aren't affected if settings change later.
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
# Extract per-user email recipients for resolution (if any)
_user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None
if output_mode == "email" and not is_audiobook:
all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", [])
if not _normalize_email_recipients(all_recipients):
return False, "No email recipients configured"
email_to, email_label, email_error = _resolve_email_recipient(
email_recipient, user_recipients=_user_email_recipients,
)
email_to, email_error = _resolve_email_destination(user_id=user_id)
if email_error:
return False, email_error
if not email_to:
return False, "Email recipient is required"
output_args = {"to": email_to, "label": email_label}
# Merge per-user overrides into output_args (only known keys)
_ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"}
if user_overrides:
for k, v in user_overrides.items():
if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args:
output_args[k] = v
output_args = {"to": email_to}
# Create a source-agnostic download task
task = DownloadTask(
@@ -215,10 +173,8 @@ def queue_book(
def queue_release(
release_data: dict,
priority: int = 0,
email_recipient: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
user_overrides: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Optional[str]]:
"""Add a release to the download queue. Returns (success, error_message)."""
try:
@@ -236,36 +192,22 @@ def queue_release(
series_position = release_data.get('series_position') or extra.get('series_position')
subtitle = release_data.get('subtitle') or extra.get('subtitle')
books_output_mode = str(config.get("BOOKS_OUTPUT_MODE", "folder") or "folder").strip().lower()
books_output_mode = str(
config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder"
).strip().lower()
is_audiobook = check_audiobook(content_type)
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
# Extract per-user email recipients for resolution (if any)
_user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None
if output_mode == "email" and not is_audiobook:
all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", [])
if not _normalize_email_recipients(all_recipients):
return False, "No email recipients configured"
email_to, email_label, email_error = _resolve_email_recipient(
email_recipient, user_recipients=_user_email_recipients,
)
email_to, email_error = _resolve_email_destination(user_id=user_id)
if email_error:
return False, email_error
if not email_to:
return False, "Email recipient is required"
output_args = {"to": email_to, "label": email_label}
# Merge per-user overrides into output_args (only known keys)
_ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"}
if user_overrides:
for k, v in user_overrides.items():
if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args:
output_args[k] = v
output_args = {"to": email_to}
# Create a source-agnostic download task from release data
task = DownloadTask(

View File

@@ -50,7 +50,7 @@ def _parse_int(value: Any, label: str) -> int:
def build_booklore_config(
values: Mapping[str, Any],
user_overrides: Optional[Dict[str, Any]] = None,
user_id: Optional[int] = None,
) -> BookloreConfig:
base_url = str(values.get("BOOKLORE_HOST", "")).strip()
username = str(values.get("BOOKLORE_USERNAME", "")).strip()
@@ -63,12 +63,21 @@ def build_booklore_config(
if not password:
raise BookloreError("Booklore password is required")
# Per-user library/path overrides (auth stays global)
overrides = user_overrides or {}
_lib_override = overrides.get("booklore_library_id")
library_id_val = _lib_override if _lib_override is not None else values.get("BOOKLORE_LIBRARY_ID")
_path_override = overrides.get("booklore_path_id")
path_id_val = _path_override if _path_override is not None else values.get("BOOKLORE_PATH_ID")
# Resolve library/path through config so user override precedence is centralized.
if user_id is not None:
library_id_val = core_config.config.get(
"BOOKLORE_LIBRARY_ID",
values.get("BOOKLORE_LIBRARY_ID"),
user_id=user_id,
)
path_id_val = core_config.config.get(
"BOOKLORE_PATH_ID",
values.get("BOOKLORE_PATH_ID"),
user_id=user_id,
)
else:
library_id_val = values.get("BOOKLORE_LIBRARY_ID")
path_id_val = values.get("BOOKLORE_PATH_ID")
library_id = _parse_int(library_id_val, "Booklore library ID")
path_id = _parse_int(path_id_val, "Booklore path ID")
@@ -221,7 +230,7 @@ def _post_process_booklore(
try:
booklore_config = build_booklore_config(
_get_booklore_settings(),
user_overrides=task.output_args if task.output_args else None,
user_id=task.user_id,
)
except BookloreError as e:
logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e)

View File

@@ -59,12 +59,7 @@ def validate_destination(destination: Path, status_callback) -> bool:
def get_final_destination(task: DownloadTask) -> Path:
"""Get final destination directory, with content-type routing and per-user override support."""
# Per-user destination override (set by admin in user settings)
user_dest = task.output_args.get("destination", "")
if user_dest:
return Path(user_dest)
"""Get final destination directory, with content-type routing support."""
is_audiobook = check_audiobook(task.content_type)
@@ -73,4 +68,4 @@ def get_final_destination(task: DownloadTask) -> Path:
if override:
return override
return get_destination(is_audiobook)
return get_destination(is_audiobook, user_id=task.user_id, username=task.username)

View File

@@ -27,6 +27,15 @@ from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import SearchFilters
from shelfmark.core.prefix_middleware import PrefixMiddleware
from shelfmark.core.auth_modes import (
determine_auth_mode,
get_auth_check_admin_status,
has_local_password_admin,
is_settings_or_onboarding_path,
should_restrict_settings_to_admin,
)
from shelfmark.core.cwa_user_sync import upsert_cwa_user
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.utils import normalize_base_path
from shelfmark.api.websocket import ws_manager
@@ -181,28 +190,20 @@ def get_client_ip() -> str:
def get_auth_mode() -> str:
"""Determine which authentication mode is active.
Priority:
1. CWA (if enabled in settings and DB path exists)
2. Built-in credentials (if configured)
3. No auth required or error -> "none"
Uses configured AUTH_METHOD plus runtime prerequisites.
Returns "none" when config is invalid or unavailable.
"""
from shelfmark.core.settings_registry import load_config_file
try:
security_config = load_config_file("security")
auth_mode = security_config.get("AUTH_METHOD", "none")
if auth_mode == "cwa" and CWA_DB_PATH:
return "cwa"
if auth_mode == "builtin" and security_config.get("BUILTIN_USERNAME") and security_config.get("BUILTIN_PASSWORD_HASH"):
return "builtin"
if auth_mode == "proxy" and security_config.get("PROXY_AUTH_USER_HEADER"):
return "proxy"
if auth_mode == "oidc" and security_config.get("OIDC_DISCOVERY_URL") and security_config.get("OIDC_CLIENT_ID"):
return "oidc"
return determine_auth_mode(
security_config,
CWA_DB_PATH,
has_local_admin=has_local_password_admin(user_db),
)
except Exception:
pass
return "none"
return "none"
# Enable CORS in development mode for local frontend development
@@ -327,26 +328,51 @@ def proxy_auth_middleware():
logger.warning(f"Proxy auth enabled but no username found in header '{user_header}'")
return jsonify({"error": "Authentication required. Proxy header not set."}), 401
# Check if settings access should be restricted to admins
restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = True # Default to admin if not restricting
if restrict_to_admin:
admin_group_header = security_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
admin_group_name = security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "admins")
# Extract groups from proxy header (can be comma or pipe separated)
# Resolve admin role for proxy sessions.
# If an admin group is configured, derive from groups header.
# Otherwise preserve existing DB role for known users and default
# first-time users to admin (to avoid lockouts).
admin_group_header = security_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
admin_group_name = str(security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "") or "").strip()
is_admin = True
if admin_group_name:
groups_header = get_proxy_header(admin_group_header) or ""
user_groups_delimiter = "," if "," in groups_header else "|"
user_groups = [g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()]
is_admin = admin_group_name in user_groups
elif user_db is not None:
existing_db_user = user_db.get_user(username=username)
if existing_db_user:
is_admin = existing_db_user.get("role") == "admin"
# Create or update session
previous_username = session.get('user_id')
if previous_username and previous_username != username:
# Header identity changed mid-session; force reprovision for the new user.
session.pop('db_user_id', None)
session['user_id'] = username
session['is_admin'] = is_admin
# Provision proxy-authenticated users into users.db for multi-user features.
if user_db is not None and 'db_user_id' not in session:
role = "admin" if is_admin else "user"
db_user, _ = upsert_external_user(
user_db,
auth_source="proxy",
username=username,
role=role,
collision_strategy="takeover",
context="proxy_request",
)
if db_user is None:
raise RuntimeError("Unexpected proxy user sync result: no user returned")
session['db_user_id'] = db_user["id"]
session.permanent = False
return None
except Exception as e:
@@ -371,22 +397,13 @@ def login_required(f):
if 'user_id' not in session:
return jsonify({"error": "Unauthorized"}), 401
# Check admin access for settings endpoints (proxy, CWA, OIDC, and builtin modes)
if auth_mode in ("proxy", "cwa", "oidc", "builtin") and (request.path.startswith('/api/settings') or request.path.startswith('/api/onboarding')):
# Check admin access for settings/onboarding endpoints.
if is_settings_or_onboarding_path(request.path):
from shelfmark.core.settings_registry import load_config_file
try:
security_config = load_config_file("security")
if auth_mode == "builtin":
# Builtin multi-user: settings are always admin-only
restrict_to_admin = 'db_user_id' in session
elif auth_mode == "proxy":
restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
else:
# For OIDC and CWA, settings are always admin-only
# The user's admin status comes from their group or database role
restrict_to_admin = True
users_config = load_config_file("users")
restrict_to_admin = should_restrict_settings_to_admin(users_config)
if restrict_to_admin and not session.get('is_admin', False):
return jsonify({"error": "Admin access required"}), 403
@@ -592,14 +609,12 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
try:
priority = int(request.args.get('priority', 0))
email_recipient = request.args.get('email_recipient')
# Per-user download overrides
db_user_id = session.get('db_user_id')
_username = session.get('user_id')
_user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {}
success, error_msg = backend.queue_book(
book_id, priority, email_recipient=email_recipient,
user_id=db_user_id, username=_username, user_overrides=_user_overrides,
book_id, priority,
user_id=db_user_id, username=_username,
)
if success:
return jsonify({"status": "queued", "priority": priority})
@@ -638,14 +653,12 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": "source_id is required"}), 400
priority = data.get('priority', 0)
email_recipient = data.get('email_recipient')
# Per-user download overrides
db_user_id = session.get('db_user_id')
_username = session.get('user_id')
_user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {}
success, error_msg = backend.queue_release(
data, priority, email_recipient=email_recipient,
user_id=db_user_id, username=_username, user_overrides=_user_overrides,
data, priority,
user_id=db_user_id, username=_username,
)
if success:
@@ -674,22 +687,6 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
from shelfmark.config.env import _is_config_dir_writable
from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete
def _normalize_email_recipients(value: Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
recipients: list[dict[str, str]] = []
for entry in value:
if not isinstance(entry, dict):
continue
nickname = str(entry.get("nickname", "") or "").strip()
email = str(entry.get("email", "") or "").strip()
if not nickname or not email:
continue
recipients.append({"nickname": nickname, "email": email})
return recipients
config = {
"calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""),
"audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""),
@@ -705,9 +702,6 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
"metadata_search_fields": get_provider_search_fields(),
"default_release_source": app_config.get("DEFAULT_RELEASE_SOURCE", "direct_download"),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
# Safe-to-expose subset of email output settings (recipients only).
# SMTP credentials are configured via the settings UI but are never returned to the frontend.
"email_recipients": _normalize_email_recipients(app_config.get("EMAIL_RECIPIENTS", []) or []),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False),
"settings_enabled": _is_config_dir_writable(),
@@ -1066,8 +1060,6 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
Returns:
flask.Response: JSON with success status or error message.
"""
from shelfmark.core.settings_registry import load_config_file
try:
ip_address = get_client_ip()
data = request.get_json()
@@ -1111,22 +1103,8 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
try:
db_user = user_db.get_user(username=username)
# If user not in DB, try legacy config credentials and auto-migrate
if not db_user:
security_config = load_config_file("security")
stored_username = security_config.get("BUILTIN_USERNAME", "")
stored_hash = security_config.get("BUILTIN_PASSWORD_HASH", "")
if username == stored_username and stored_hash and check_password_hash(stored_hash, password):
# Auto-migrate: create admin user in DB from config
db_user = user_db.create_user(
username=stored_username,
password_hash=stored_hash,
role="admin",
)
logger.info(f"Migrated builtin admin '{stored_username}' to users database")
else:
return _failed_login_response(username, ip_address)
return _failed_login_response(username, ip_address)
# Authenticate against DB user
if db_user:
@@ -1160,7 +1138,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password, role FROM user WHERE name = ?", (username,))
cur.execute("SELECT password, role, email FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
@@ -1171,10 +1149,25 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
user_role = row[1] if row[1] is not None else 0
is_admin = (user_role & 1) == 1
cwa_email = row[2] or None
db_user_id = None
if user_db is not None:
role = "admin" if is_admin else "user"
db_user, _ = upsert_cwa_user(
user_db,
cwa_username=username,
cwa_email=cwa_email,
role=role,
context="cwa_login",
)
db_user_id = db_user["id"]
# Successful authentication - create session and clear failed attempts
session['user_id'] = username
session['is_admin'] = is_admin
if db_user_id is not None:
session['db_user_id'] = db_user_id
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, is_admin={is_admin}, remember_me={remember_me})")
@@ -1234,6 +1227,7 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
try:
security_config = load_config_file("security")
users_config = load_config_file("users")
auth_mode = get_auth_mode()
# If no authentication is configured, access is allowed (full admin)
@@ -1248,35 +1242,28 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
# Check if user has a valid session
is_authenticated = 'user_id' in session
# Determine admin status for settings access
# - Built-in auth: check DB user role (legacy single-user is always admin)
# - CWA auth: check RESTRICT_SETTINGS_TO_ADMIN setting
# - Proxy auth: check PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN setting
if auth_mode == "builtin":
is_admin = session.get('is_admin', True)
elif auth_mode == "cwa":
restrict_to_admin = security_config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
if restrict_to_admin:
is_admin = session.get('is_admin', False)
else:
# All authenticated CWA users can access settings
is_admin = True
elif auth_mode == "proxy":
restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get('is_admin', not restrict_to_admin)
elif auth_mode == "oidc":
# OIDC admin status is determined by group membership during login
# and stored in session['is_admin'] - use it directly
is_admin = session.get('is_admin', False)
else:
is_admin = False
is_admin = get_auth_check_admin_status(auth_mode, users_config, session)
display_name = None
if is_authenticated and session.get('db_user_id'):
try:
from shelfmark.core.user_db import UserDB
import os
user_db = UserDB(os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db"))
user_db.initialize()
db_user = user_db.get_user(user_id=session['db_user_id'])
if db_user:
display_name = db_user.get("display_name") or None
except Exception:
pass
response_data = {
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False,
"username": session.get('user_id') if is_authenticated else None
"username": session.get('user_id') if is_authenticated else None,
"display_name": display_name,
}
# Add logout URL for proxy auth if configured
@@ -1284,6 +1271,12 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
logout_url = security_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
response_data["logout_url"] = logout_url
# Add custom OIDC button label if configured
if auth_mode == "oidc":
oidc_button_label = security_config.get("OIDC_BUTTON_LABEL", "")
if oidc_button_label:
response_data["oidc_button_label"] = oidc_button_label
return jsonify(response_data)
except Exception as e:

View File

@@ -19,7 +19,6 @@ import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
import { ResultsSection } from './components/ResultsSection';
import { DetailsModal } from './components/DetailsModal';
import { EmailRecipientModal } from './components/EmailRecipientModal';
import { ReleaseModal } from './components/ReleaseModal';
import { DownloadsSidebar } from './components/DownloadsSidebar';
import { ToastContainer } from './components/ToastContainer';
@@ -30,7 +29,6 @@ import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { UserCancelledError, isUserCancelledError } from './utils/errors';
import { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
@@ -80,6 +78,9 @@ function App() {
authChecked,
isAdmin,
authMode,
username,
displayName,
oidcButtonLabel,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -145,49 +146,6 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [emailRecipientModalOpen, setEmailRecipientModalOpen] = useState(false);
const emailRecipientPromiseRef = useRef<{
resolve: (nickname: string) => void;
reject: (error: Error) => void;
} | null>(null);
const openEmailRecipientPicker = useCallback(async (): Promise<string> => {
const recipients = config?.email_recipients ?? [];
if (!config || config.books_output_mode !== 'email') {
return '';
}
if (recipients.length === 0) {
throw new Error('No email recipients configured');
}
if (recipients.length === 1) {
return recipients[0]?.nickname ?? '';
}
if (emailRecipientPromiseRef.current) {
throw new Error('Email recipient picker already open');
}
setEmailRecipientModalOpen(true);
return new Promise((resolve, reject) => {
emailRecipientPromiseRef.current = { resolve, reject };
});
}, [config]);
const handleEmailRecipientSelect = useCallback((nickname: string) => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.resolve(nickname);
}, []);
const handleEmailRecipientCancel = useCallback(() => {
const pending = emailRecipientPromiseRef.current;
emailRecipientPromiseRef.current = null;
setEmailRecipientModalOpen(false);
pending?.reject(new UserCancelledError());
}, []);
// Expose debug function to trigger onboarding from browser console
useEffect(() => {
@@ -482,18 +440,9 @@ function App() {
// Download book
const handleDownload = async (book: Book): Promise<void> => {
try {
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && contentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
await downloadBook(book.id, emailRecipient);
await downloadBook(book.id);
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
// Important: rethrow so buttons can reset their "Queuing..." state.
throw error;
}
console.error('Download failed:', error);
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
@@ -546,11 +495,6 @@ function App() {
// Handle download from ReleaseModal
const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => {
try {
let emailRecipient: string | undefined;
if (config?.books_output_mode === 'email' && releaseContentType === 'ebook') {
emailRecipient = await openEmailRecipientPicker();
}
trackRelease(book.id, release.source_id);
await downloadRelease({
@@ -572,13 +516,9 @@ function App() {
series_name: book.series_name,
series_position: book.series_position,
subtitle: book.subtitle,
email_recipient: emailRecipient,
});
await fetchStatus();
} catch (error) {
if (isUserCancelledError(error)) {
throw error;
}
console.error('Release download failed:', error);
showToast(error instanceof Error ? error.message : 'Failed to queue download', 'error');
throw error;
@@ -630,13 +570,16 @@ function App() {
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
onSettingsClick={isAdmin ? () => {
onSettingsClick={() => {
if (config?.settings_enabled) {
setSettingsOpen(true);
} else {
setConfigBannerOpen(true);
}
} : undefined}
}}
isAdmin={isAdmin}
username={username}
displayName={displayName}
statusCounts={statusCounts}
onLogoClick={() => handleResetSearch(config)}
authRequired={authRequired}
@@ -750,13 +693,6 @@ function App() {
/>
)}
<EmailRecipientModal
isOpen={emailRecipientModalOpen}
recipients={config?.email_recipients ?? []}
onSelect={handleEmailRecipientSelect}
onCancel={handleEmailRecipientCancel}
/>
</main>
<Footer
@@ -776,6 +712,7 @@ function App() {
<SettingsModal
isOpen={settingsOpen}
authMode={authMode}
onClose={() => setSettingsOpen(false)}
onShowToast={showToast}
onSettingsSaved={handleSettingsSaved}
@@ -856,6 +793,7 @@ function App() {
error={loginError}
isLoading={isLoggingIn}
authMode={authMode}
oidcButtonLabel={oidcButtonLabel}
/>
)
}

View File

@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { EmailRecipient } from '../types';
interface EmailRecipientModalProps {
isOpen: boolean;
recipients: EmailRecipient[];
onSelect: (nickname: string) => void;
onCancel: () => void;
}
export const EmailRecipientModal = ({ isOpen, recipients, onSelect, onCancel }: EmailRecipientModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [selectedNickname, setSelectedNickname] = useState<string | null>(null);
// Reset selection when modal opens/closes
useEffect(() => {
if (isOpen) setSelectedNickname(null);
}, [isOpen]);
const handleCancel = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setTimeout(() => {
onCancel();
setIsClosing(false);
}, 150);
}, [onCancel, isClosing]);
const handleSend = useCallback(() => {
if (isClosing || !selectedNickname) return;
setIsClosing(true);
setTimeout(() => {
onSelect(selectedNickname);
setIsClosing(false);
}, 150);
}, [onSelect, selectedNickname, isClosing]);
// ESC to cancel
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleCancel]);
// Prevent body scroll while open
useEffect(() => {
if (!isOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
if (!isOpen) return null;
const titleId = 'email-recipient-modal-title';
return (
<div
className="modal-overlay active sm:px-6 sm:py-6"
onClick={(e) => {
if (e.target === e.currentTarget) handleCancel();
}}
>
<div
className={`details-container w-full sm:max-w-md h-full sm:h-auto pointer-events-auto ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="flex h-full sm:h-auto flex-col overflow-hidden rounded-none sm:rounded-2xl border-0 sm:border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)] shadow-none sm:shadow-2xl">
<header className="flex items-start gap-4 border-b border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4">
<div className="flex-1 space-y-1">
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Output</p>
<h3 id={titleId} className="text-lg font-semibold leading-snug">
Send via Email
</h3>
<p className="text-sm text-gray-600 dark:text-gray-300">
Choose a recipient for this download.
</p>
</div>
<button
type="button"
onClick={handleCancel}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Cancel"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-6">
{recipients.length === 0 ? (
<div className="rounded-2xl border border-[var(--border-muted)] bg-[var(--bg-soft)] px-4 py-3 text-sm">
No recipients configured.
</div>
) : (
<div className="space-y-2">
{recipients.map((r) => {
const isSelected = selectedNickname === r.nickname;
return (
<button
key={`${r.nickname}:${r.email}`}
type="button"
onClick={() => setSelectedNickname(isSelected ? null : r.nickname)}
className={`w-full text-left rounded-2xl border px-4 py-3 transition-colors hover-action ${
isSelected
? 'border-sky-500 bg-sky-500/10'
: 'border-[var(--border-muted)] bg-[var(--bg-soft)]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
isSelected ? 'border-sky-500 bg-sky-500' : 'border-gray-400 dark:border-gray-500'
}`}
>
{isSelected && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="min-w-0">
<div className="font-medium truncate">{r.nickname}</div>
<div className="text-xs opacity-70 truncate">{r.email}</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Desktop footer: small Send + Cancel buttons */}
<footer className="hidden sm:flex border-t border-[var(--border-muted)] bg-[var(--bg)] px-5 py-4 justify-end gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleSend}
disabled={!selectedNickname}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
Send
</button>
</footer>
{/* Mobile: full-width Send button, slides up when a recipient is selected */}
{selectedNickname && (
<div
className="sm:hidden flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
type="button"
onClick={handleSend}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700"
>
Send
</button>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -26,10 +26,13 @@ interface HeaderProps {
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
isAdmin?: boolean;
statusCounts?: StatusCounts;
onLogoClick?: () => void;
authRequired?: boolean;
isAuthenticated?: boolean;
username?: string | null;
displayName?: string | null;
onLogout?: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string;
onRemoveToast?: (id: string) => void;
@@ -50,10 +53,13 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
isLoading = false,
onDownloadsClick,
onSettingsClick,
isAdmin = false,
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
onLogoClick,
authRequired = false,
isAuthenticated = false,
username,
displayName,
onLogout,
onShowToast,
onRemoveToast,
@@ -311,11 +317,14 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
{onSettingsClick && (
<button
type="button"
onClick={() => {
onClick={isAdmin ? () => {
closeDropdown();
onSettingsClick();
}}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
} : undefined}
disabled={!isAdmin}
className={`w-full text-left px-4 py-2 transition-colors flex items-center gap-3 ${
isAdmin ? 'hover-surface' : 'opacity-40 cursor-not-allowed'
}`}
>
<svg
className="w-5 h-5"
@@ -409,18 +418,36 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</>
)}
{/* Logout Button */}
{authRequired && isAuthenticated && onLogout && (
<button
type="button"
onClick={handleLogout}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
{/* User Footer */}
{authRequired && isAuthenticated && username && (
<div
className="border-t"
style={{ borderColor: 'var(--border-muted)' }}
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
<span>Sign Out</span>
</button>
<div className="px-4 py-3 flex items-center gap-2.5">
<span
className="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 uppercase"
style={{ backgroundColor: 'var(--hover-surface)', color: 'var(--text)' }}
>
{(displayName || username).slice(0, 2)}
</span>
<div className="flex-1 min-w-0 truncate text-sm font-medium">
{displayName || username}
</div>
{onLogout && (
<button
type="button"
onClick={handleLogout}
className="shrink-0 p-2 rounded-full hover-action transition-colors text-red-600 dark:text-red-400"
title="Sign Out"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
</button>
)}
</div>
</div>
)}
</div>
</div>

View File

@@ -8,6 +8,7 @@ interface LoginFormProps {
isLoading?: boolean;
autoFocus?: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
}
const EyeIcon = () => (
@@ -49,13 +50,15 @@ const EyeSlashIcon = () => (
</svg>
);
export const LoginForm = ({
const PasswordLoginForm = ({
onSubmit,
error = null,
isLoading = false,
autoFocus = true,
authMode,
}: LoginFormProps) => {
isLoading,
autoFocus,
}: {
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
autoFocus: boolean;
}) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
@@ -69,6 +72,164 @@ export const LoginForm = ({
}
}, [autoFocus]);
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<form
method="post"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={onSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
);
};
export const LoginForm = ({
onSubmit,
error = null,
isLoading = false,
autoFocus = true,
authMode,
oidcButtonLabel,
}: LoginFormProps) => {
const isOidc = authMode === 'oidc';
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
// Auto-expand password form if there's an error (likely from a password attempt)
useEffect(() => {
if (error && isOidc) {
setShowPasswordLogin(true);
}
}, [error, isOidc]);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -79,18 +240,11 @@ export const LoginForm = ({
onSubmit({
username: usernameValue,
password: passwordValue,
remember_me: rememberMe,
remember_me: formData.has('remember_me'),
});
}
};
const handleUsernameKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
passwordRef.current?.focus();
}
};
return (
<div>
{error && (
@@ -98,154 +252,36 @@ export const LoginForm = ({
{error}
</div>
)}
<form
method="post"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
data-form-type="login"
onSubmit={handleSubmit}
>
<div className="mb-4">
<label htmlFor="username" className="block text-sm font-medium mb-2">
Username
</label>
<input
ref={usernameRef}
type="text"
id="username"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="next"
value={username}
onChange={(event) => setUsername(event.target.value)}
onKeyDown={handleUsernameKeyDown}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<div className="relative">
<input
ref={passwordRef}
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
autoComplete="current-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
inputMode="text"
enterKeyHint="go"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={isLoading}
className="w-full px-4 py-2.5 rounded-lg border focus:outline-none focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed pr-10 transition-colors"
style={{
backgroundColor: 'var(--input-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
required
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon /> : <EyeIcon />}
</button>
</div>
</div>
<div className="mb-6 flex items-center">
<input
type="checkbox"
id="remember-me"
name="remember_me"
checked={rememberMe}
onChange={(event) => setRememberMe(event.target.checked)}
disabled={isLoading}
className="w-4 h-4 rounded focus:ring-2 focus:ring-sky-500 disabled:opacity-50 disabled:cursor-not-allowed accent-sky-900"
style={{ borderColor: 'var(--border-color)' }}
/>
<label htmlFor="remember-me" className="ml-2 text-sm">
Remember me for 7 days
</label>
</div>
<button
type="submit"
name="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed bg-sky-700 hover:bg-sky-800 disabled:hover:bg-sky-700"
aria-label="Sign in"
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
{authMode === 'oidc' && (
{isOidc ? (
<>
<div className="flex items-center my-4">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<span className="px-3 text-sm opacity-60">or</span>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
<a
href={withBasePath('/api/auth/oidc/login')}
className="w-full py-2.5 px-4 rounded-lg font-medium text-center transition-colors border block"
style={{
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
className="w-full py-2.5 px-4 rounded-lg font-medium text-white text-center transition-colors block bg-sky-700 hover:bg-sky-800"
>
Sign in with OIDC
{oidcButtonLabel || 'Sign in with OIDC'}
</a>
<div className="flex items-center mt-5 mb-2">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<button
type="button"
onClick={() => setShowPasswordLogin((prev) => !prev)}
className="px-3 text-sm opacity-60 hover:opacity-100 transition-opacity"
>
{showPasswordLogin ? 'Hide' : 'Use password'}
</button>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
{showPasswordLogin && (
<div className="pt-2">
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={true} />
</div>
)}
</>
) : (
<PasswordLoginForm onSubmit={handleSubmit} isLoading={isLoading} autoFocus={autoFocus} />
)}
</div>
);

View File

@@ -41,6 +41,8 @@ interface SettingsContentProps {
isSaving: boolean;
hasChanges: boolean;
isUniversalMode?: boolean; // Whether app is in Universal search mode
overrideSummary?: Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>;
embedded?: boolean;
}
function evaluateShowWhenCondition(
@@ -245,15 +247,20 @@ export const SettingsContent = ({
isSaving,
hasChanges,
isUniversalMode = true,
overrideSummary,
embedded = false,
}: SettingsContentProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Reset scroll position when tab changes
useEffect(() => {
if (embedded) {
return;
}
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [tab.name]);
}, [embedded, tab.name]);
// Memoize the visible fields to avoid recalculating on every render
const visibleFields = useMemo(
@@ -261,6 +268,77 @@ export const SettingsContent = ({
[tab.fields, values, isUniversalMode]
);
const renderedFields = (
<div className="space-y-5">
{visibleFields.map((field) => {
const disabledState = getDisabledState(field, values);
const fieldOverrideSummary = overrideSummary?.[field.key];
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
userOverrideCount={fieldOverrideSummary?.count}
userOverrideDetails={fieldOverrideSummary?.users}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values
)}
</FieldWrapper>
);
})}
</div>
);
const saveButton = hasChanges ? (
<button
onClick={onSave}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
) : null;
if (embedded) {
return (
<div className="space-y-5">
{renderedFields}
{saveButton}
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Scrollable content area */}
@@ -269,28 +347,7 @@ export const SettingsContent = ({
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
>
<div className="space-y-5">
{visibleFields.map((field) => {
const disabledState = getDisabledState(field, values);
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values
)}
</FieldWrapper>
);
})}
</div>
{renderedFields}
</div>
{/* Save button - only visible when there are changes */}
@@ -299,37 +356,7 @@ export const SettingsContent = ({
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
onClick={onSave}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
{saveButton}
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useSettings } from '../../hooks/useSettings';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { getAdminSettingsOverridesSummary } from '../../services/api';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
@@ -8,12 +9,13 @@ import { UsersPanel } from './UsersPanel';
interface SettingsModalProps {
isOpen: boolean;
authMode: string;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
}
export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
export const SettingsModal = ({ isOpen, authMode, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
const {
tabs,
groups,
@@ -35,6 +37,10 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
const [isMobile, setIsMobile] = useState(false);
const [showMobileDetail, setShowMobileDetail] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [usersSubpageState, setUsersSubpageState] = useState<{ title: string; onBack: () => void } | null>(null);
const [tabOverrideSummaries, setTabOverrideSummaries] = useState<
Record<string, Record<string, { count: number; users: Array<{ userId: number; username: string; value: unknown }> }>>
>({});
// Track previous isOpen state to detect modal open transition
const prevIsOpenRef = useRef(false);
@@ -91,9 +97,44 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
if (isOpen) {
setShowMobileDetail(false);
setIsClosing(false);
setUsersSubpageState(null);
setTabOverrideSummaries({});
}
}, [isOpen]);
useEffect(() => {
if (selectedTab !== 'users') {
setUsersSubpageState(null);
}
}, [selectedTab]);
useEffect(() => {
if (!isOpen || !selectedTab || selectedTab === 'users') {
return;
}
let cancelled = false;
getAdminSettingsOverridesSummary(selectedTab)
.then((data) => {
if (cancelled) return;
setTabOverrideSummaries((prev) => ({
...prev,
[selectedTab]: data.keys || {},
}));
})
.catch(() => {
if (cancelled) return;
setTabOverrideSummaries((prev) => ({
...prev,
[selectedTab]: {},
}));
});
return () => {
cancelled = true;
};
}, [isOpen, selectedTab]);
// Reset to first tab when modal transitions from closed to open
useEffect(() => {
const justOpened = isOpen && !prevIsOpenRef.current;
@@ -142,9 +183,17 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
if (!selectedTab) {
return { success: false, message: 'No tab selected' };
}
if (selectedTab === 'security' && actionKey === 'open_users_tab') {
setSelectedTab('users');
if (isMobile) {
setShowMobileDetail(true);
}
return { success: true, message: 'Opening Users tab...' };
}
return executeAction(selectedTab, actionKey);
},
[selectedTab, executeAction]
[selectedTab, executeAction, isMobile, setSelectedTab]
);
// Memoize the field change handler to prevent creating new functions on every render
@@ -194,6 +243,37 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
const currentTab = tabs.find((t) => t.name === selectedTab);
const currentTabDisplayName = currentTab?.displayName || 'Settings';
const usersHeaderTitle = usersSubpageState ? `Settings / ${usersSubpageState.title}` : null;
const selectedAuthMethod = values.security?.AUTH_METHOD;
const usersAuthMode = typeof selectedAuthMethod === 'string' ? selectedAuthMethod : authMode;
const currentTabContent = currentTab
? (selectedTab === 'users' ? (
<UsersPanel
authMode={usersAuthMode}
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
onShowToast={onShowToast}
onSubpageStateChange={setUsersSubpageState}
/>
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
overrideSummary={tabOverrideSummaries[currentTab.name]}
/>
))
: null;
// Loading state
if (isLoading) {
@@ -301,27 +381,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
// Detail view
<>
<SettingsHeader
title={currentTabDisplayName}
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : currentTabDisplayName}
showBack
onBack={handleBack}
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : handleBack}
onClose={handleClose}
/>
{currentTab && (
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
)}
{currentTabContent}
</>
)}
</div>
@@ -350,7 +415,12 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
aria-modal="true"
aria-label="Settings"
>
<SettingsHeader title="Settings" onClose={handleClose} />
<SettingsHeader
title={selectedTab === 'users' && usersHeaderTitle ? usersHeaderTitle : 'Settings'}
showBack={selectedTab === 'users' && !!usersSubpageState}
onBack={selectedTab === 'users' && usersSubpageState ? usersSubpageState.onBack : undefined}
onClose={handleClose}
/>
<div className="flex flex-1 min-h-0">
<SettingsSidebar
@@ -361,22 +431,7 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
mode="sidebar"
/>
{currentTab ? (
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
) : (
{currentTabContent ?? (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
Select a category to configure
</div>

View File

@@ -1,212 +1,182 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect } from 'react';
import { AdminUser } from '../../services/api';
import { ActionResult, SettingsTab } from '../../types/settings';
import {
AdminUser,
BookloreOption,
DownloadDefaults,
getAdminUsers,
getAdminUser,
getBookloreOptions,
getDownloadDefaults,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from '../../services/api';
canCreateLocalUsersForAuthMode,
UserListView,
UserOverridesView,
useUserForm,
useUserMutations,
useUsersFetch,
useUsersPanelState,
} from './users';
import { SettingsContent } from './SettingsContent';
import { SettingsSubpage } from './shared';
interface UsersPanelProps {
authMode: string;
tab: SettingsTab;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
onSave: () => Promise<void>;
onAction: (key: string) => Promise<ActionResult>;
isSaving: boolean;
hasChanges: boolean;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSubpageStateChange?: (state: { title: string; onBack: () => void } | null) => void;
}
const inputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500 transition-colors';
export const UsersPanel = ({
authMode,
tab,
values,
onChange,
onSave,
onAction,
isSaving,
hasChanges,
onShowToast,
onSubpageStateChange,
}: UsersPanelProps) => {
const { route, openCreate, openEdit, openEditOverrides, backToList } = useUsersPanelState();
const disabledInputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-50 cursor-not-allowed';
const {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
} = useUsersFetch({ onShowToast });
interface PerUserSettings {
destination?: string;
booklore_library_id?: string;
booklore_path_id?: string;
email_recipients?: Array<{ nickname: string; email: string }>;
}
const {
createForm,
setCreateForm,
resetCreateForm,
editingUser,
setEditingUser,
editPassword,
setEditPassword,
editPasswordConfirm,
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
beginEditing,
applyUserEditContext,
resetEditContext,
clearEditState,
userOverridableSettings,
} = useUserForm();
export const UsersPanel = ({ onShowToast }: UsersPanelProps) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', display_name: '', role: 'user' });
const [creating, setCreating] = useState(false);
const {
creating,
saving,
deletingUserId,
syncingCwa,
createUser,
saveEditedUser,
deleteUser,
syncCwaUsers,
} = useUserMutations({
onShowToast,
fetchUsers,
createForm,
resetCreateForm,
editingUser,
editPassword,
editPasswordConfirm,
userSettings,
userOverridableSettings,
deliveryPreferences,
onEditSaveSuccess: clearEditState,
});
// Edit view state
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
const [bookloreLibraries, setBookloreLibraries] = useState<BookloreOption[]>([]);
const [booklorePaths, setBooklorePaths] = useState<BookloreOption[]>([]);
const fetchUsers = useCallback(async () => {
const startEditing = async (user: AdminUser) => {
beginEditing(user);
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(msg);
onShowToast?.(msg, 'error');
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const startEditing = useCallback(async (user: AdminUser) => {
setEditingUser({ ...user });
setEditPassword('');
setEditPasswordConfirm('');
// Fetch full user data (with settings) and download defaults in parallel
try {
const [fullUser, defaults] = await Promise.all([
getAdminUser(user.id),
getDownloadDefaults(),
]);
setDownloadDefaults(defaults);
const settings = (fullUser.settings || {}) as PerUserSettings;
setUserSettings(settings);
// Fetch BookLore options if in booklore mode
if (defaults.BOOKS_OUTPUT_MODE === 'booklore') {
try {
const blOptions = await getBookloreOptions();
setBookloreLibraries(blOptions.libraries || []);
setBooklorePaths(blOptions.paths || []);
} catch {
setBookloreLibraries([]);
setBooklorePaths([]);
}
}
// Set override toggles based on which settings exist
setOverrides({
destination: !!settings.destination,
booklore_library_id: !!settings.booklore_library_id,
booklore_path_id: !!settings.booklore_path_id,
email_recipients: !!settings.email_recipients?.length,
});
const context = await fetchUserEditContext(user.id);
applyUserEditContext(context);
} catch {
setDownloadDefaults(null);
setUserSettings({});
setOverrides({});
}
}, []);
const handleDelete = async (userId: number) => {
try {
await deleteAdminUser(userId);
setConfirmDelete(null);
onShowToast?.('User deleted', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to delete user', 'error');
resetEditContext();
}
};
const handleSaveEdit = async () => {
if (!editingUser) return;
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
// Validate password if provided
if (editPassword) {
if (editPassword.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
if (editPassword !== editPasswordConfirm) {
onShowToast?.('Passwords do not match', 'error');
return;
}
}
const handleBackToList = () => {
clearEditState();
backToList();
};
// Build settings payload: include overridden values, null out cleared overrides
const settingsPayload: Record<string, unknown> = {};
if (overrides.destination) {
settingsPayload.destination = userSettings.destination || '';
} else {
settingsPayload.destination = null;
}
if (overrides.booklore_library_id) {
settingsPayload.booklore_library_id = userSettings.booklore_library_id || '';
} else {
settingsPayload.booklore_library_id = null;
}
if (overrides.booklore_path_id) {
settingsPayload.booklore_path_id = userSettings.booklore_path_id || '';
} else {
settingsPayload.booklore_path_id = null;
}
if (overrides.email_recipients) {
settingsPayload.email_recipients = userSettings.email_recipients || [];
} else {
settingsPayload.email_recipients = null;
}
// Skip sending role when it's managed by OIDC group auth
const roleManaged = !!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true;
try {
await updateAdminUser(editingUser.id, {
email: editingUser.email,
display_name: editingUser.display_name,
...(!roleManaged ? { role: editingUser.role } : {}),
...(editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length ? { settings: settingsPayload } : {}),
});
setEditingUser(null);
onShowToast?.('User updated', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to update user', 'error');
}
const handleCancelCreate = () => {
resetCreateForm();
backToList();
};
const handleCreate = async () => {
if (!createForm.username || !createForm.password) {
onShowToast?.('Username and password are required', 'error');
return;
}
if (createForm.password.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
setCreating(true);
try {
const data = await createAdminUser(createForm as { username: string; password: string; email?: string; display_name?: string; role?: string });
setShowCreateForm(false);
setCreateForm({ username: '', email: '', password: '', display_name: '', role: 'user' });
onShowToast?.(`User ${data.username} created`, 'success');
fetchUsers();
} catch (err) {
onShowToast?.((err as Error).message || 'Failed to create user', 'error');
} finally {
setCreating(false);
const ok = await createUser();
if (ok) {
backToList();
}
};
const toggleOverride = (key: string, enabled: boolean) => {
setOverrides((prev) => ({ ...prev, [key]: enabled }));
if (!enabled) {
setUserSettings((prev) => {
const next = { ...prev };
(next as Record<string, unknown>)[key] = null;
return next;
const handleOpenOverrides = () => {
if (editingUser) {
openEditOverrides(editingUser.id);
}
};
const handleEdit = async (user: AdminUser) => {
openEdit(user.id);
await startEditing(user);
};
const handleSyncCwa = async () => {
await syncCwaUsers();
};
const handleBackToEdit = () => {
if (editingUser) {
openEdit(editingUser.id);
return;
}
backToList();
};
useEffect(() => {
if (!onSubpageStateChange) {
return undefined;
}
if (route.kind === 'edit-overrides') {
const username = editingUser && editingUser.id === route.userId
? editingUser.username
: 'User';
onSubpageStateChange({
title: `Users / User Preferences: ${username}`,
onBack: handleBackToEdit,
});
} else {
onSubpageStateChange(null);
}
return () => {
onSubpageStateChange(null);
};
}, [editingUser, handleBackToEdit, onSubpageStateChange, route]);
useEffect(() => {
if (route.kind === 'create' && !canCreateLocalUsers) {
backToList();
}
}, [backToList, canCreateLocalUsers, route.kind]);
const handleSave = async () => {
const ok = await saveEditedUser();
if (ok) {
backToList();
}
};
@@ -233,511 +203,75 @@ export const UsersPanel = ({ onShowToast }: UsersPanelProps) => {
);
}
// Edit view
if (editingUser) {
const outputMode = downloadDefaults?.BOOKS_OUTPUT_MODE || 'folder';
if (route.kind === 'edit-overrides') {
if (!editingUser || editingUser.id !== route.userId) {
return (
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
Loading user details...
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setEditingUser(null)}
className="text-sm opacity-60 hover:opacity-100 transition-opacity"
>
&larr; Back
</button>
<h3 className="text-sm font-medium">Edit {editingUser.username}</h3>
</div>
<div className="space-y-5 max-w-lg">
{editingUser.oidc_subject && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
This user authenticates via SSO. Password is managed by the identity provider.
</div>
{downloadDefaults?.OIDC_USE_ADMIN_GROUP === true && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
{downloadDefaults?.OIDC_ADMIN_GROUP
? `Admin role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Admin group authorization is enabled but no group name is configured.'}
</div>
)}
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={editingUser.display_name || ''}
onChange={(e) => setEditingUser({ ...editingUser, display_name: e.target.value || null })}
className={inputClasses}
placeholder="Display name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={editingUser.email || ''}
onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value || null })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
{/* Hide role dropdown for OIDC users when admin group auth is on (like password) */}
{!(!!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true) && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Role</label>
<select
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value })}
className={inputClasses}
>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
)}
{/* Password section */}
{!editingUser.oidc_subject && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">New Password</label>
<input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className={inputClasses}
placeholder="Leave empty to keep current"
/>
</div>
{editPassword && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Confirm Password</label>
<input
type="password"
value={editPasswordConfirm}
onChange={(e) => setEditPasswordConfirm(e.target.value)}
className={inputClasses}
placeholder="Confirm new password"
/>
</div>
)}
</>
)}
{/* Per-user download settings overrides */}
{downloadDefaults && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-1">Download Settings Overrides</p>
<p className="text-xs opacity-40 mb-3">Override global defaults for this user.</p>
</div>
{/* Destination override (shown for folder mode) */}
{(outputMode === 'folder' || outputMode === 'booklore') && (
<OverrideField
label="Destination Folder"
enabled={overrides.destination || false}
onToggle={(v) => toggleOverride('destination', v)}
globalValue={downloadDefaults.DESTINATION || '/books'}
>
<input
type="text"
value={userSettings.destination || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, destination: e.target.value }))}
className={overrides.destination ? inputClasses : disabledInputClasses}
disabled={!overrides.destination}
placeholder={downloadDefaults.DESTINATION || '/books'}
/>
</OverrideField>
)}
{/* BookLore overrides */}
{outputMode === 'booklore' && (
<>
<OverrideField
label="BookLore Library"
enabled={overrides.booklore_library_id || false}
onToggle={(v) => toggleOverride('booklore_library_id', v)}
globalValue={
bookloreLibraries.find((l) => l.value === downloadDefaults.BOOKLORE_LIBRARY_ID)?.label
|| downloadDefaults.BOOKLORE_LIBRARY_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_library_id || ''}
onChange={(e) => {
setUserSettings((s) => ({ ...s, booklore_library_id: e.target.value, booklore_path_id: '' }));
// Reset path override when library changes
if (overrides.booklore_path_id) {
setOverrides((o) => ({ ...o, booklore_path_id: true }));
}
}}
className={overrides.booklore_library_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_library_id}
>
<option value="">Select library...</option>
{bookloreLibraries.map((lib) => (
<option key={lib.value} value={lib.value}>{lib.label}</option>
))}
</select>
</OverrideField>
<OverrideField
label="BookLore Path"
enabled={overrides.booklore_path_id || false}
onToggle={(v) => toggleOverride('booklore_path_id', v)}
globalValue={
booklorePaths.find((p) => p.value === downloadDefaults.BOOKLORE_PATH_ID)?.label
|| downloadDefaults.BOOKLORE_PATH_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_path_id || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, booklore_path_id: e.target.value }))}
className={overrides.booklore_path_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_path_id}
>
<option value="">Select path...</option>
{booklorePaths
.filter((p) => {
const selectedLib = userSettings.booklore_library_id || downloadDefaults.BOOKLORE_LIBRARY_ID;
return !p.childOf || p.childOf === selectedLib;
})
.map((path) => (
<option key={path.value} value={path.value}>{path.label}</option>
))}
</select>
</OverrideField>
</>
)}
{/* Email recipients override */}
{outputMode === 'email' && (
<OverrideField
label="Email Recipients"
enabled={overrides.email_recipients || false}
onToggle={(v) => toggleOverride('email_recipients', v)}
globalValue={
downloadDefaults.EMAIL_RECIPIENTS?.length
? downloadDefaults.EMAIL_RECIPIENTS.map((r) => r.nickname || r.email).join(', ')
: 'None configured'
}
>
{overrides.email_recipients && (
<EmailRecipientsEditor
recipients={userSettings.email_recipients || []}
onChange={(r) => setUserSettings((s) => ({ ...s, email_recipients: r }))}
/>
)}
</OverrideField>
)}
</>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2.5 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
<UserOverridesView
onSave={handleSave}
saving={saving}
onBack={handleBackToEdit}
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
);
}
// List view
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-xs opacity-60">
Users are created automatically via OIDC login, or manually below.
</p>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-3 py-1.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors shrink-0"
>
{showCreateForm ? 'Cancel' : 'Create User'}
</button>
<SettingsSubpage>
<div>
<UserListView
authMode={authMode}
users={users}
onCreate={openCreate}
showCreateForm={route.kind === 'create'}
createForm={createForm}
onCreateFormChange={setCreateForm}
creating={creating}
isFirstUser={users.length === 0}
onCreateSubmit={handleCreate}
onCancelCreate={handleCancelCreate}
showEditForm={route.kind === 'edit'}
activeEditUserId={route.kind === 'edit' ? route.userId : null}
editingUser={route.kind === 'edit' ? editingUser : null}
onEditingUserChange={setEditingUser}
onEditSave={handleSave}
saving={saving}
onCancelEdit={handleBackToList}
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
downloadDefaults={downloadDefaults}
onOpenOverrides={handleOpenOverrides}
onEdit={handleEdit}
onDelete={deleteUser}
deletingUserId={deletingUserId}
onSyncCwa={handleSyncCwa}
syncingCwa={syncingCwa}
/>
<div className="pt-5 mt-4 border-t border-black/10 dark:border-white/10">
<SettingsContent
tab={tab}
values={values}
onChange={onChange}
onSave={onSave}
onAction={onAction}
isSaving={isSaving}
hasChanges={hasChanges}
embedded
/>
</div>
</div>
{showCreateForm && (
<div className="mb-4 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] space-y-3">
{users.length === 0 && (
<p className="text-xs opacity-60 pb-1">
This will be the first account and will be created as admin.
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Username <span className="text-red-500">*</span></label>
<input
type="text"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
className={inputClasses}
placeholder="username"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={createForm.display_name}
onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })}
className={inputClasses}
placeholder="Display Name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password <span className="text-red-500">*</span></label>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
className={inputClasses}
placeholder="Min 4 characters"
/>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value })}
className="px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm transition-colors"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={handleCreate}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)}
{users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium
${user.oidc_subject
? 'bg-sky-500/15 text-sky-400'
: 'bg-zinc-500/15 opacity-70'}`}
>
{user.oidc_subject ? 'OIDC' : 'Password'}
</span>
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-xs px-2 py-0.5 rounded font-medium
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{user.role}
</span>
<button
onClick={() => startEditing(user)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Edit
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)] text-red-400
hover:bg-red-600 hover:text-white hover:border-red-600 transition-colors"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface OverrideFieldProps {
label: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
globalValue: string;
children: React.ReactNode;
}
const OverrideField = ({ label, enabled, onToggle, globalValue, children }: OverrideFieldProps) => (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{label}</label>
<button
type="button"
onClick={() => onToggle(!enabled)}
className={`text-[10px] px-2 py-0.5 rounded font-medium transition-colors
${enabled
? 'bg-sky-500/15 text-sky-400 hover:bg-sky-500/25'
: 'bg-zinc-500/10 opacity-60 hover:opacity-80'}`}
>
{enabled ? 'Custom' : 'Global'}
</button>
</div>
{!enabled && (
<p className="text-xs opacity-40">Using global: {globalValue}</p>
)}
{children}
</div>
);
interface EmailRecipientsEditorProps {
recipients: Array<{ nickname: string; email: string }>;
onChange: (recipients: Array<{ nickname: string; email: string }>) => void;
}
const EmailRecipientsEditor = ({ recipients, onChange }: EmailRecipientsEditorProps) => {
const addRecipient = () => {
onChange([...recipients, { nickname: '', email: '' }]);
};
const removeRecipient = (index: number) => {
onChange(recipients.filter((_, i) => i !== index));
};
const updateRecipient = (index: number, field: 'nickname' | 'email', value: string) => {
const updated = [...recipients];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<div className="space-y-2">
{recipients.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={r.nickname}
onChange={(e) => updateRecipient(i, 'nickname', e.target.value)}
className={inputClasses}
placeholder="Nickname"
/>
<input
type="email"
value={r.email}
onChange={(e) => updateRecipient(i, 'email', e.target.value)}
className={inputClasses}
placeholder="email@example.com"
/>
<button
type="button"
onClick={() => removeRecipient(i)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-600 hover:text-white transition-colors shrink-0"
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={addRecipient}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
+ Add Recipient
</button>
</div>
</SettingsSubpage>
);
};

View File

@@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { SettingsField } from '../../../types/settings';
import { Tooltip } from '../../shared/Tooltip';
import { EnvLockBadge } from './EnvLockBadge';
interface FieldWrapperProps {
@@ -8,6 +9,13 @@ interface FieldWrapperProps {
// Optional overrides for dynamic disabled state (from disabledWhen)
disabledOverride?: boolean;
disabledReasonOverride?: string;
headerRight?: ReactNode;
userOverrideCount?: number;
userOverrideDetails?: Array<{
userId: number;
username: string;
value: unknown;
}>;
}
// Badge shown when a field is disabled
@@ -60,11 +68,60 @@ const RestartRequiredBadge = () => (
</span>
);
const UserOverriddenBadge = ({
count,
details = [],
}: {
count: number;
details?: Array<{ userId: number; username: string; value: unknown }>;
}) => {
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '(empty)';
if (typeof value === 'string') return value || '(empty)';
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const visibleDetails = details.slice(0, 10);
const extraCount = Math.max(details.length - visibleDetails.length, 0);
const content = (
<div className="space-y-1 max-w-xs">
{visibleDetails.map((entry) => (
<div key={entry.userId} className="text-[11px] leading-snug">
<span className="font-medium">{entry.username}</span>
<span className="opacity-70">: {formatValue(entry.value)}</span>
</div>
))}
{extraCount > 0 && (
<div className="text-[11px] opacity-70">and {extraCount} more...</div>
)}
</div>
);
return (
<Tooltip content={content} position="top">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-sky-500/15 text-sky-500 dark:text-sky-400 border border-sky-500/30"
>
User overridden{count > 1 ? ` (${count})` : ''}
</span>
</Tooltip>
);
};
export const FieldWrapper = ({
field,
children,
disabledOverride,
disabledReasonOverride,
headerRight,
userOverrideCount,
userOverrideDetails,
}: FieldWrapperProps) => {
// Action buttons, headings, and table fields handle their own layout
// Table fields have column headers, so they don't need a separate label
@@ -82,25 +139,34 @@ export const FieldWrapper = ({
const isFullyDimmed = isDisabled && !field.fromEnv;
return (
<div className={`space-y-1.5 ${isFullyDimmed ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-2">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
<div className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
{Boolean(userOverrideCount) && (userOverrideCount || 0) > 0 && (
<UserOverriddenBadge
count={userOverrideCount || 0}
details={userOverrideDetails}
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">{headerRight}</div>
</div>
{children}
<div className={isFullyDimmed ? 'opacity-50' : ''}>{children}</div>
{field.description && (
<p className="text-xs opacity-60">{field.description}</p>
)}
{isDisabled && disabledReason && (
<p className="text-xs text-zinc-500 italic">{disabledReason}</p>
<p className="text-xs text-zinc-400 italic">{disabledReason}</p>
)}
</div>
);

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
interface SettingsSubpageProps {
children: ReactNode;
}
export const SettingsSubpage = ({
children,
}: SettingsSubpageProps) => {
return (
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
);
};

View File

@@ -1,2 +1,3 @@
export { EnvLockBadge } from './EnvLockBadge';
export { FieldWrapper } from './FieldWrapper';
export { SettingsSubpage } from './SettingsSubpage';

View File

@@ -0,0 +1,29 @@
import { AdminUser } from '../../../services/api';
import {
AUTH_SOURCE_BADGE_CLASSES,
AUTH_SOURCE_LABEL,
} from './types';
interface UserAuthSourceBadgeProps {
user: AdminUser;
showInactive?: boolean;
}
export const UserAuthSourceBadge = ({ user, showInactive = true }: UserAuthSourceBadgeProps) => {
const authSource = user.auth_source;
const active = user.is_active !== false;
const badgeBase = 'inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none';
return (
<>
<span className={`${badgeBase} ${AUTH_SOURCE_BADGE_CLASSES[authSource]}`}>
{AUTH_SOURCE_LABEL[authSource]}
</span>
{showInactive && !active && (
<span className={`${badgeBase} bg-zinc-500/10 opacity-80`}>
Inactive
</span>
)}
</>
);
};

View File

@@ -0,0 +1,276 @@
import { ReactNode } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
import { PasswordField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
const UserCardShell = ({ title, children }: { title: string; children: ReactNode }) => (
<div className="space-y-5 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg)]">
<h3 className="text-sm font-medium">{title}</h3>
{children}
</div>
);
const CREATE_ROLE_OPTIONS: SelectOption[] = [
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
];
const EDIT_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
const createTextField = (
key: string,
label: string,
value: string,
placeholder: string,
required = false,
): TextFieldConfig => ({
type: 'TextField',
key,
label,
value,
placeholder,
required,
});
const createPasswordField = (
key: string,
label: string,
value: string,
placeholder: string,
required = false,
): PasswordFieldConfig => ({
type: 'PasswordField',
key,
label,
value,
placeholder,
required,
});
const createRoleField = (value: string, options: SelectOption[]): SelectFieldConfig => ({
type: 'SelectField',
key: 'role',
label: 'Role',
value,
options,
});
const renderTextField = (
field: TextFieldConfig,
value: string,
onChange: (value: string) => void,
disabled = false,
disabledReason?: string,
) => (
<FieldWrapper field={field} disabledOverride={disabled} disabledReasonOverride={disabledReason}>
<TextField field={field} value={value} onChange={onChange} disabled={disabled} />
</FieldWrapper>
);
const renderSelectField = (
field: SelectFieldConfig,
value: string,
onChange: (value: string) => void,
disabled = false,
disabledReason?: string,
) => (
<FieldWrapper field={field} disabledOverride={disabled} disabledReasonOverride={disabledReason}>
<SelectField field={field} value={value} onChange={onChange} disabled={disabled} />
</FieldWrapper>
);
const renderPasswordField = (
field: PasswordFieldConfig,
value: string,
onChange: (value: string) => void,
) => (
<FieldWrapper field={field}>
<PasswordField field={field} value={value} onChange={onChange} />
</FieldWrapper>
);
interface UserCreateCardProps {
form: CreateUserFormState;
onChange: (form: CreateUserFormState) => void;
creating: boolean;
isFirstUser: boolean;
onSubmit: () => void;
onCancel: () => void;
}
export const UserCreateCard = ({
form,
onChange,
creating,
isFirstUser,
onSubmit,
onCancel,
}: UserCreateCardProps) => {
const usernameField = createTextField('username', 'Username', form.username, 'username', true);
const displayNameField = createTextField('display_name', 'Display Name', form.display_name, 'Display name');
const emailField = createTextField('email', 'Email', form.email, 'user@example.com');
const passwordField = createPasswordField('password', 'Password', form.password, 'Min 4 characters', true);
const roleField = createRoleField(form.role, CREATE_ROLE_OPTIONS);
return (
<UserCardShell title="Create Local User">
{isFirstUser && (
<p className="text-xs text-zinc-500">
This will be the first account and will be created as admin.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{renderTextField(usernameField, form.username, (value) => onChange({ ...form, username: value }))}
{renderTextField(displayNameField, form.display_name, (value) => onChange({ ...form, display_name: value }))}
{renderTextField(emailField, form.email, (value) => onChange({ ...form, email: value }))}
{renderPasswordField(passwordField, form.password, (value) => onChange({ ...form, password: value }))}
</div>
{renderSelectField(roleField, form.role, (value) => onChange({ ...form, role: value }))}
<div className="flex items-center gap-2 pt-1">
<button
onClick={onSubmit}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Create Local User'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</UserCardShell>
);
};
interface UserEditFieldsProps {
user: AdminUser;
onUserChange: (user: AdminUser) => void;
onSave: () => void;
saving: boolean;
onCancel: () => void;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
onEditPasswordConfirmChange: (value: string) => void;
downloadDefaults: DownloadDefaults | null;
onEditOverrides?: () => void;
}
export const UserEditFields = ({
user,
onUserChange,
onSave,
saving,
onCancel,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
downloadDefaults,
onEditOverrides,
}: UserEditFieldsProps) => {
const capabilities = user.edit_capabilities;
const { authSource, canSetPassword, canEditRole, canEditEmail, canEditDisplayName } = capabilities;
const displayNameField = createTextField('display_name', 'Display Name', user.display_name || '', 'Display name');
const emailField = createTextField('email', 'Email', user.email || '', 'user@example.com');
const roleField = createRoleField(user.role, EDIT_ROLE_OPTIONS);
const newPasswordField = createPasswordField('new_password', 'New Password', editPassword, 'Leave empty to keep current');
const confirmPasswordField = createPasswordField('confirm_password', 'Confirm Password', editPasswordConfirm, 'Confirm new password', true);
const displayNameDisabledReason = !canEditDisplayName
? 'Display name is managed by the identity provider.'
: undefined;
const emailDisabledReason = !canEditEmail
? (authSource === 'cwa'
? 'Email is synced from Calibre-Web.'
: 'Email is managed by your identity provider.')
: undefined;
const roleDisabledReason = !canEditRole
? (authSource === 'oidc'
? (downloadDefaults?.OIDC_ADMIN_GROUP
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Role is managed by OIDC group authorization.')
: 'Role is managed by the external authentication source.')
: undefined;
return (
<>
{renderTextField(
displayNameField,
user.display_name || '',
(value) => onUserChange({ ...user, display_name: value || null }),
!canEditDisplayName,
displayNameDisabledReason,
)}
{renderTextField(
emailField,
user.email || '',
(value) => onUserChange({ ...user, email: value || null }),
!canEditEmail,
emailDisabledReason,
)}
{renderSelectField(
roleField,
user.role,
(value) => onUserChange({ ...user, role: value }),
!canEditRole,
roleDisabledReason,
)}
{canSetPassword && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
{renderPasswordField(newPasswordField, editPassword, onEditPasswordChange)}
{editPassword && renderPasswordField(confirmPasswordField, editPasswordConfirm, onEditPasswordConfirmChange)}
</>
)}
<div className="flex flex-wrap gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
{onEditOverrides && (
<button
onClick={onEditOverrides}
className="ml-auto px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
User Preferences
</button>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,284 @@
import { useState } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import {
canCreateLocalUsersForAuthMode,
CreateUserFormState,
getUsersHeadingDescriptionForAuthMode,
} from './types';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { UserCreateCard, UserEditFields } from './UserCard';
import { HeadingField } from '../fields';
import { HeadingFieldConfig } from '../../../types/settings';
interface UserListViewProps {
authMode: string;
users: AdminUser[];
onCreate: () => void;
showCreateForm: boolean;
createForm: CreateUserFormState;
onCreateFormChange: (form: CreateUserFormState) => void;
creating: boolean;
isFirstUser: boolean;
onCreateSubmit: () => void;
onCancelCreate: () => void;
showEditForm: boolean;
activeEditUserId: number | null;
editingUser: AdminUser | null;
onEditingUserChange: (user: AdminUser) => void;
onEditSave: () => void;
saving: boolean;
onCancelEdit: () => void;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
onEditPasswordConfirmChange: (value: string) => void;
downloadDefaults: DownloadDefaults | null;
onOpenOverrides: () => void;
onEdit: (user: AdminUser) => void;
onDelete: (userId: number) => Promise<boolean>;
deletingUserId: number | null;
onSyncCwa: () => Promise<void> | void;
syncingCwa: boolean;
}
export const UserListView = ({
authMode,
users,
onCreate,
showCreateForm,
createForm,
onCreateFormChange,
creating,
isFirstUser,
onCreateSubmit,
onCancelCreate,
showEditForm,
activeEditUserId,
editingUser,
onEditingUserChange,
onEditSave,
saving,
onCancelEdit,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
downloadDefaults,
onOpenOverrides,
onEdit,
onDelete,
deletingUserId,
onSyncCwa,
syncingCwa,
}: UserListViewProps) => {
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const canCreateLocalUsers = canCreateLocalUsersForAuthMode(authMode);
const isCwaMode = String(authMode || 'none').toLowerCase() === 'cwa';
const usersHeading: HeadingFieldConfig = {
key: 'users_heading',
type: 'HeadingField',
title: 'Users',
description: getUsersHeadingDescriptionForAuthMode(authMode),
};
const handleDelete = async (userId: number) => {
const ok = await onDelete(userId);
if (ok) {
setConfirmDelete(null);
}
};
return (
<div className="space-y-4">
<div>
<HeadingField field={usersHeading} />
</div>
{users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => {
const active = user.is_active !== false;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
return (
<div
key={user.id}
className={`rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] transition-colors ${active ? '' : 'opacity-60'}`}
>
<div className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<UserAuthSourceBadge user={user} />
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{!active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{roleLabel}
</span>
{!isEditingRow && (
<>
<button
onClick={() => onEdit(user)}
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
aria-label={`Edit ${user.username}`}
title="Edit user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
disabled={deletingUserId === user.id}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="p-2 rounded-full hover-action transition-colors text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
aria-label={`Delete ${user.username}`}
title="Delete user"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-[18px] h-[18px]"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
)}
</>
)}
</div>
</div>
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<UserEditFields
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
downloadDefaults={downloadDefaults}
onEditOverrides={onOpenOverrides}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{canCreateLocalUsers && (
<div>
{showCreateForm ? (
<UserCreateCard
form={createForm}
onChange={onCreateFormChange}
creating={creating}
isFirstUser={isFirstUser}
onSubmit={onCreateSubmit}
onCancel={onCancelCreate}
/>
) : (
<button
onClick={onCreate}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Create Local User
</button>
)}
</div>
)}
{!canCreateLocalUsers && isCwaMode && (
<div>
<button
onClick={onSyncCwa}
disabled={syncingCwa}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{syncingCwa ? 'Syncing with CWA...' : 'Sync with CWA'}
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,384 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import {
HeadingFieldConfig,
SelectFieldConfig,
SettingsField,
TextFieldConfig,
} from '../../../types/settings';
import { HeadingField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { PerUserSettings } from './types';
interface UserOverridesSectionProps {
deliveryPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
}
const modeOptions = [
{ value: 'folder', label: 'Folder' },
{ value: 'email', label: 'Email (SMTP)' },
{ value: 'booklore', label: 'BookLore (API)' },
];
const fallbackOutputModeField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKS_OUTPUT_MODE',
label: 'Output Mode',
description: 'Choose where completed book files are sent.',
value: 'folder',
options: modeOptions,
};
const fallbackDestinationField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION',
label: 'Destination',
description: 'Directory where downloaded files are saved.',
value: '',
placeholder: '/books',
};
const fallbackDestinationAudiobookField: TextFieldConfig = {
type: 'TextField',
key: 'DESTINATION_AUDIOBOOK',
label: 'Destination',
description: 'Directory where downloaded audiobook files are saved. Use {User} for per-user folders and leave empty to use the books destination.',
value: '',
placeholder: '/audiobooks',
};
const fallbackBookloreLibraryField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKLORE_LIBRARY_ID',
label: 'Library',
description: 'BookLore library to upload into.',
value: '',
options: [],
};
const fallbackBooklorePathField: SelectFieldConfig = {
type: 'SelectField',
key: 'BOOKLORE_PATH_ID',
label: 'Path',
description: 'BookLore library path for uploads.',
value: '',
options: [],
filterByField: 'BOOKLORE_LIBRARY_ID',
};
const fallbackEmailRecipientField: TextFieldConfig = {
type: 'TextField',
key: 'EMAIL_RECIPIENT',
label: 'Email Recipient',
description: 'Email address used for this user in Email output mode.',
value: '',
placeholder: 'reader@example.com',
};
type DeliverySettingKey = keyof PerUserSettings;
function normalizeMode(value: unknown): 'folder' | 'booklore' | 'email' {
const mode = String(value || '').trim().toLowerCase();
if (mode === 'booklore' || mode === 'email') {
return mode;
}
return 'folder';
}
function toStringValue(value: unknown): string {
if (value === undefined || value === null) return '';
return String(value);
}
function getFieldByKey<T extends SettingsField>(
fields: SettingsField[] | undefined,
key: string,
fallback: T
): T {
const found = fields?.find((field) => field.key === key);
if (!found) {
return fallback;
}
return found as T;
}
interface ResetOverrideButtonProps {
disabled?: boolean;
label?: string;
onClick: () => void;
}
const ResetOverrideButton = ({ disabled = false, label = 'Reset', onClick }: ResetOverrideButtonProps) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{label}
</button>
);
const deliveryHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_heading',
title: 'Delivery Preferences',
description: 'Editing values here creates per-user settings. Use Reset to inherit global values.',
};
const booksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_books_heading',
title: 'Books',
description: 'Output mode and destination behavior for ebooks, comics, and magazines.',
};
const audiobooksHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'delivery_preferences_audiobooks_heading',
title: 'Audiobooks',
description: 'Audiobooks always use folder output. Set a user-specific destination or leave empty to inherit books.',
};
const BOOK_PREFERENCE_KEYS: DeliverySettingKey[] = [
'BOOKS_OUTPUT_MODE',
'DESTINATION',
'BOOKLORE_LIBRARY_ID',
'BOOKLORE_PATH_ID',
'EMAIL_RECIPIENT',
];
const AUDIOBOOK_PREFERENCE_KEYS: DeliverySettingKey[] = ['DESTINATION_AUDIOBOOK'];
export const UserOverridesSection = ({
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
}: UserOverridesSectionProps) => {
const fields = deliveryPreferences?.fields ?? [];
const globalValues = deliveryPreferences?.globalValues ?? {};
const preferenceKeys = deliveryPreferences?.keys ?? [];
const outputModeField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKS_OUTPUT_MODE', fallbackOutputModeField);
const destinationField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION', fallbackDestinationField);
const destinationAudiobookField = getFieldByKey<TextFieldConfig>(fields, 'DESTINATION_AUDIOBOOK', fallbackDestinationAudiobookField);
const bookloreLibraryField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_LIBRARY_ID', fallbackBookloreLibraryField);
const booklorePathField = getFieldByKey<SelectFieldConfig>(fields, 'BOOKLORE_PATH_ID', fallbackBooklorePathField);
const emailRecipientField = getFieldByKey<TextFieldConfig>(fields, 'EMAIL_RECIPIENT', fallbackEmailRecipientField);
const isOverridden = (key: DeliverySettingKey): boolean =>
Object.prototype.hasOwnProperty.call(userSettings, key) &&
userSettings[key] !== null &&
userSettings[key] !== undefined;
const resetKeys = (keys: DeliverySettingKey[]) => {
setUserSettings((prev) => {
const next = { ...prev };
keys.forEach((key) => {
delete next[key];
});
return next;
});
};
const readValue = (key: DeliverySettingKey, fallback = ''): string => {
if (isOverridden(key)) {
return toStringValue(userSettings[key]);
}
if (key in globalValues) {
return toStringValue(globalValues[key]);
}
return fallback;
};
const outputModeValue = readValue('BOOKS_OUTPUT_MODE', 'folder');
const effectiveOutputMode = normalizeMode(outputModeValue);
const destinationValue = readValue('DESTINATION');
const destinationAudiobookValue = readValue('DESTINATION_AUDIOBOOK');
const libraryValue = readValue('BOOKLORE_LIBRARY_ID');
const pathValue = readValue('BOOKLORE_PATH_ID');
const emailRecipientValue = readValue('EMAIL_RECIPIENT');
const availableBookPreferenceKeys = BOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const availableAudiobookPreferenceKeys = AUDIOBOOK_PREFERENCE_KEYS.filter((key) => preferenceKeys.includes(String(key)));
const hasBookDeliveryOverride = availableBookPreferenceKeys.some((key) => isOverridden(key));
const hasAudiobookDeliveryOverride = availableAudiobookPreferenceKeys.some((key) => isOverridden(key));
const canOverrideOutputMode = isUserOverridable('BOOKS_OUTPUT_MODE');
const canOverrideDestination = isUserOverridable('DESTINATION');
const canOverrideAudiobookDestination = isUserOverridable('DESTINATION_AUDIOBOOK');
const canOverrideBookloreLibrary = isUserOverridable('BOOKLORE_LIBRARY_ID');
const canOverrideBooklorePath = isUserOverridable('BOOKLORE_PATH_ID');
const canOverrideEmailRecipient = isUserOverridable('EMAIL_RECIPIENT');
if (!deliveryPreferences) {
return null;
}
return (
<div className="space-y-4">
<HeadingField field={deliveryHeading} />
<HeadingField field={booksHeading} />
{canOverrideOutputMode && (
<FieldWrapper
field={outputModeField}
headerRight={
hasBookDeliveryOverride ? (
<ResetOverrideButton
label="Reset all"
onClick={() => resetKeys(availableBookPreferenceKeys)}
/>
) : undefined
}
>
<SelectField
field={outputModeField}
value={outputModeValue}
onChange={(value) => {
setUserSettings((prev) => {
const next: PerUserSettings = { ...prev, BOOKS_OUTPUT_MODE: value };
if (value === 'folder') {
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
delete next.EMAIL_RECIPIENT;
} else if (value === 'booklore') {
delete next.DESTINATION;
delete next.EMAIL_RECIPIENT;
} else if (value === 'email') {
delete next.DESTINATION;
delete next.BOOKLORE_LIBRARY_ID;
delete next.BOOKLORE_PATH_ID;
}
return next;
});
}}
disabled={Boolean(outputModeField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'folder' && canOverrideDestination && (
<FieldWrapper
field={destinationField}
headerRight={
isOverridden('DESTINATION') ? (
<ResetOverrideButton
disabled={Boolean(destinationField.fromEnv)}
onClick={() => resetKeys(['DESTINATION'])}
/>
) : undefined
}
>
<TextField
field={destinationField}
value={destinationValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION: value }))}
disabled={Boolean(destinationField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'booklore' && canOverrideBookloreLibrary && (
<FieldWrapper
field={bookloreLibraryField}
headerRight={
isOverridden('BOOKLORE_LIBRARY_ID') ? (
<ResetOverrideButton
disabled={Boolean(bookloreLibraryField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_LIBRARY_ID'])}
/>
) : undefined
}
>
<SelectField
field={bookloreLibraryField}
value={libraryValue}
onChange={(value) => {
setUserSettings((prev) => ({
...prev,
BOOKLORE_LIBRARY_ID: value,
BOOKLORE_PATH_ID: '',
}));
}}
disabled={Boolean(bookloreLibraryField.fromEnv)}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'booklore' && canOverrideBooklorePath && (
<FieldWrapper
field={booklorePathField}
headerRight={
isOverridden('BOOKLORE_PATH_ID') ? (
<ResetOverrideButton
disabled={Boolean(booklorePathField.fromEnv)}
onClick={() => resetKeys(['BOOKLORE_PATH_ID'])}
/>
) : undefined
}
>
<SelectField
field={booklorePathField}
value={pathValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, BOOKLORE_PATH_ID: value }))}
disabled={Boolean(booklorePathField.fromEnv)}
filterValue={libraryValue || undefined}
/>
</FieldWrapper>
)}
{effectiveOutputMode === 'email' && canOverrideEmailRecipient && (
<FieldWrapper
field={emailRecipientField}
headerRight={
isOverridden('EMAIL_RECIPIENT') ? (
<ResetOverrideButton
disabled={Boolean(emailRecipientField.fromEnv)}
onClick={() => resetKeys(['EMAIL_RECIPIENT'])}
/>
) : undefined
}
>
<TextField
field={emailRecipientField}
value={emailRecipientValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, EMAIL_RECIPIENT: value }))}
disabled={Boolean(emailRecipientField.fromEnv)}
/>
</FieldWrapper>
)}
{canOverrideAudiobookDestination && (
<>
<HeadingField field={audiobooksHeading} />
<FieldWrapper
field={destinationAudiobookField}
headerRight={
hasAudiobookDeliveryOverride ? (
<ResetOverrideButton
disabled={Boolean(destinationAudiobookField.fromEnv)}
onClick={() => resetKeys(availableAudiobookPreferenceKeys)}
/>
) : undefined
}
>
<TextField
field={destinationAudiobookField}
value={destinationAudiobookValue}
onChange={(value) => setUserSettings((prev) => ({ ...prev, DESTINATION_AUDIOBOOK: value }))}
disabled={Boolean(destinationAudiobookField.fromEnv)}
/>
</FieldWrapper>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,52 @@
import { DeliveryPreferencesResponse } from '../../../services/api';
import { PerUserSettings } from './types';
import { SettingsSubpage } from '../shared';
import { UserOverridesSection } from './UserOverridesSection';
interface UserOverridesViewProps {
onSave: () => void;
saving: boolean;
onBack: () => void;
deliveryPreferences: DeliveryPreferencesResponse | null;
isUserOverridable: (key: keyof PerUserSettings) => boolean;
userSettings: PerUserSettings;
setUserSettings: (updater: (prev: PerUserSettings) => PerUserSettings) => void;
}
export const UserOverridesView = ({
onSave,
saving,
onBack,
deliveryPreferences,
isUserOverridable,
userSettings,
setUserSettings,
}: UserOverridesViewProps) => (
<SettingsSubpage>
<div className="space-y-5">
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={setUserSettings}
/>
<div className="flex gap-2 pt-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onBack}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Back
</button>
</div>
</div>
</SettingsSubpage>
);

View File

@@ -0,0 +1,10 @@
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
export { UserCreateCard, UserEditFields } from './UserCard';
export { UserListView } from './UserListView';
export { UserOverridesSection } from './UserOverridesSection';
export { UserOverridesView } from './UserOverridesView';
export { useUserForm } from './useUserForm';
export { useUserMutations } from './useUserMutations';
export { useUsersFetch } from './useUsersFetch';
export { useUsersPanelState } from './useUsersPanelState';
export { canCreateLocalUsersForAuthMode, getUsersHeadingDescriptionForAuthMode } from './types';

View File

@@ -0,0 +1,72 @@
import { AdminUser } from '../../../services/api';
export interface PerUserSettings {
[key: string]: unknown;
BOOKS_OUTPUT_MODE?: string;
DESTINATION?: string;
DESTINATION_AUDIOBOOK?: string;
BOOKLORE_LIBRARY_ID?: string;
BOOKLORE_PATH_ID?: string;
EMAIL_RECIPIENT?: string;
}
export interface CreateUserFormState {
username: string;
email: string;
password: string;
display_name: string;
role: string;
}
export const INITIAL_CREATE_FORM: CreateUserFormState = {
username: '',
email: '',
password: '',
display_name: '',
role: 'user',
};
export type UsersPanelRoute =
| { kind: 'list' }
| { kind: 'create' }
| { kind: 'edit'; userId: number }
| { kind: 'edit-overrides'; userId: number };
export type AuthSource = AdminUser['auth_source'];
export const AUTH_SOURCE_LABEL: Record<AuthSource, string> = {
builtin: 'Local',
oidc: 'OIDC',
proxy: 'Proxy',
cwa: 'CWA',
};
export const AUTH_SOURCE_BADGE_CLASSES: Record<AuthSource, string> = {
builtin: 'bg-zinc-500/15 opacity-70',
oidc: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
proxy: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
cwa: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
};
export const canCreateLocalUsersForAuthMode = (authMode?: string): boolean => {
const normalized = String(authMode || 'none').toLowerCase();
return normalized === 'none' || normalized === 'builtin' || normalized === 'oidc';
};
export const getUsersHeadingDescriptionForAuthMode = (authMode?: string): string => {
const normalized = String(authMode || 'none').toLowerCase();
if (normalized === 'builtin') {
return 'Create and manage user accounts directly. Passwords are stored locally and users sign in with their username and password.';
}
if (normalized === 'oidc') {
return 'Users sign in through your identity provider. New accounts can be created automatically on first login when auto-provisioning is enabled, or you can pre-create users here and they\u2019ll be linked by email on first sign-in.';
}
if (normalized === 'proxy') {
return 'Users are authenticated by your reverse proxy. Accounts are automatically created on first sign-in. If a local user with a matching username already exists, it will be linked instead.';
}
if (normalized === 'cwa') {
return 'User accounts are synced from your Calibre-Web database. Users are matched by email, and new accounts are created here when new CWA users are found.';
}
return 'Authentication is disabled. Anyone can access Shelfmark without signing in.';
};

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { AdminUser, DeliveryPreferencesResponse, DownloadDefaults } from '../../../services/api';
import { CreateUserFormState, INITIAL_CREATE_FORM, PerUserSettings } from './types';
import { UserEditContext } from './useUsersFetch';
export const useUserForm = () => {
const [createForm, setCreateForm] = useState<CreateUserFormState>({ ...INITIAL_CREATE_FORM });
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const resetCreateForm = () => setCreateForm({ ...INITIAL_CREATE_FORM });
const resetEditContext = () => {
setDownloadDefaults(null);
setDeliveryPreferences(null);
setUserSettings({});
setUserOverridableSettings(new Set());
};
const beginEditing = (user: AdminUser) => {
setEditingUser({ ...user });
setEditPassword('');
setEditPasswordConfirm('');
};
const applyUserEditContext = (context: UserEditContext) => {
setEditingUser({ ...context.user });
setDownloadDefaults(context.downloadDefaults);
setDeliveryPreferences(context.deliveryPreferences);
setUserSettings(context.userSettings);
setUserOverridableSettings(new Set(context.userOverridableSettings));
};
const clearEditState = () => {
setEditingUser(null);
setEditPassword('');
setEditPasswordConfirm('');
resetEditContext();
};
const isUserOverridable = (key: keyof PerUserSettings) => userOverridableSettings.has(String(key));
return {
createForm,
setCreateForm,
resetCreateForm,
editingUser,
setEditingUser,
beginEditing,
applyUserEditContext,
resetEditContext,
clearEditState,
editPassword,
setEditPassword,
editPasswordConfirm,
setEditPasswordConfirm,
downloadDefaults,
deliveryPreferences,
userSettings,
setUserSettings,
userOverridableSettings,
isUserOverridable,
};
};

View File

@@ -0,0 +1,151 @@
import { useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
createAdminUser,
deleteAdminUser,
syncAdminCwaUsers,
updateAdminUser,
} from '../../../services/api';
import { CreateUserFormState, PerUserSettings } from './types';
const MIN_PASSWORD_LENGTH = 4;
interface UseUserMutationsParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
fetchUsers: () => Promise<void>;
createForm: CreateUserFormState;
resetCreateForm: () => void;
editingUser: AdminUser | null;
editPassword: string;
editPasswordConfirm: string;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
deliveryPreferences: DeliveryPreferencesResponse | null;
onEditSaveSuccess?: () => void;
}
const getPasswordError = (password: string, passwordConfirm: string) => {
if (!password) return null;
if (password.length < MIN_PASSWORD_LENGTH) return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
return password === passwordConfirm ? null : 'Passwords do not match';
};
const buildSettingsPayload = (userSettings: PerUserSettings, userOverridableSettings: Set<string>, deliveryPreferences: DeliveryPreferencesResponse | null) =>
(deliveryPreferences?.keys || [...userOverridableSettings]).reduce<Record<string, unknown>>((payload, key) => {
const typedKey = key as keyof PerUserSettings;
payload[key] = Object.prototype.hasOwnProperty.call(userSettings, typedKey) && userSettings[typedKey] !== null && userSettings[typedKey] !== undefined
? (userSettings[typedKey] ?? '')
: null;
return payload;
}, {});
export const useUserMutations = ({
onShowToast,
fetchUsers,
createForm,
resetCreateForm,
editingUser,
editPassword,
editPasswordConfirm,
userSettings,
userOverridableSettings,
deliveryPreferences,
onEditSaveSuccess,
}: UseUserMutationsParams) => {
const [creating, setCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<number | null>(null);
const [syncingCwa, setSyncingCwa] = useState(false);
const fail = (message: string) => (onShowToast?.(message, 'error'), false);
const createUser = async () => {
if (!createForm.username || !createForm.password) return fail('Username and password are required');
if (createForm.password.length < MIN_PASSWORD_LENGTH) return fail(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
setCreating(true);
try {
const created = await createAdminUser({
username: createForm.username,
password: createForm.password,
email: createForm.email || undefined,
display_name: createForm.display_name || undefined,
role: createForm.role || undefined,
});
resetCreateForm();
onShowToast?.(`Local user ${created.username} created`, 'success');
await fetchUsers();
return true;
} catch (err) {
return fail(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setCreating(false);
}
};
const saveEditedUser = async () => {
if (!editingUser) return false;
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
if (passwordError) return fail(passwordError);
const caps = editingUser.edit_capabilities;
const settingsPayload = buildSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences);
setSaving(true);
try {
await updateAdminUser(editingUser.id, {
...(caps.canEditEmail ? { email: editingUser.email } : {}),
...(caps.canEditDisplayName ? { display_name: editingUser.display_name } : {}),
...(caps.canEditRole ? { role: editingUser.role } : {}),
...(caps.canSetPassword && editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length > 0 ? { settings: settingsPayload } : {}),
});
onEditSaveSuccess?.();
onShowToast?.('User updated', 'success');
await fetchUsers();
return true;
} catch {
return fail('Failed to update user');
} finally {
setSaving(false);
}
};
const deleteUser = async (userId: number) => {
setDeletingUserId(userId);
try {
await deleteAdminUser(userId);
onShowToast?.('User deleted', 'success');
await fetchUsers();
return true;
} catch {
return fail('Failed to delete user');
} finally {
setDeletingUserId(null);
}
};
const syncCwaUsers = async () => {
setSyncingCwa(true);
try {
const result = await syncAdminCwaUsers();
onShowToast?.(result.message || 'Users synced from CWA', 'success');
await fetchUsers();
return true;
} catch (err) {
return fail(err instanceof Error ? err.message : 'Failed to sync users from CWA');
} finally {
setSyncingCwa(false);
}
};
return {
creating,
saving,
deletingUserId,
syncingCwa,
createUser,
saveEditedUser,
deleteUser,
syncCwaUsers,
};
};

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
DownloadDefaults,
getAdminDeliveryPreferences,
getAdminUser,
getAdminUsers,
getDownloadDefaults,
} from '../../../services/api';
import { PerUserSettings } from './types';
interface UseUsersFetchParams {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export interface UserEditContext {
user: AdminUser;
downloadDefaults: DownloadDefaults;
deliveryPreferences: DeliveryPreferencesResponse | null;
userSettings: PerUserSettings;
userOverridableSettings: Set<string>;
}
export const useUsersFetch = ({ onShowToast }: UseUsersFetchParams) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(message);
onShowToast?.(message, 'error');
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
void fetchUsers();
}, [fetchUsers]);
const fetchUserEditContext = useCallback(async (userId: number): Promise<UserEditContext> => {
const [fullUser, defaults] = await Promise.all([
getAdminUser(userId),
getDownloadDefaults(),
]);
let deliveryPreferences: DeliveryPreferencesResponse | null = null;
let userSettings = (fullUser.settings || {}) as PerUserSettings;
let userOverridableSettings = new Set<string>();
try {
const preferences = await getAdminDeliveryPreferences(userId);
deliveryPreferences = preferences;
userSettings = (preferences.userOverrides || fullUser.settings || {}) as PerUserSettings;
userOverridableSettings = new Set(preferences.keys || []);
} catch {
// Delivery preference introspection is best-effort.
}
return {
user: fullUser,
downloadDefaults: defaults,
deliveryPreferences,
userSettings,
userOverridableSettings,
};
}, []);
return {
users,
loading,
loadError,
fetchUsers,
fetchUserEditContext,
};
};

View File

@@ -0,0 +1,30 @@
import { useCallback, useState } from 'react';
import { UsersPanelRoute } from './types';
export const useUsersPanelState = () => {
const [route, setRoute] = useState<UsersPanelRoute>({ kind: 'list' });
const openCreate = useCallback(() => {
setRoute({ kind: 'create' });
}, []);
const openEdit = useCallback((userId: number) => {
setRoute({ kind: 'edit', userId });
}, []);
const openEditOverrides = useCallback((userId: number) => {
setRoute({ kind: 'edit-overrides', userId });
}, []);
const backToList = useCallback(() => {
setRoute({ kind: 'list' });
}, []);
return {
route,
openCreate,
openEdit,
openEditOverrides,
backToList,
};
};

View File

@@ -87,6 +87,8 @@ export function Tooltip({
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocusCapture={showTooltip}
onBlurCapture={hideTooltip}
className="inline-flex"
>
{children}

View File

@@ -14,6 +14,9 @@ interface UseAuthReturn {
authChecked: boolean;
isAdmin: boolean;
authMode: string;
username: string | null;
displayName: string | null;
oidcButtonLabel: string | null;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -30,23 +33,27 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [authMode, setAuthMode] = useState<string>('none');
const [username, setUsername] = useState<string | null>(null);
const [displayName, setDisplayName] = useState<string | null>(null);
const [oidcButtonLabel, setOidcButtonLabel] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
const applyAuthResponse = useCallback((response: Awaited<ReturnType<typeof checkAuth>>) => {
setAuthRequired(response.auth_required !== false);
setIsAuthenticated(response.authenticated || false);
setIsAdmin(response.is_admin || false);
setAuthMode(response.auth_mode || 'none');
setUsername(response.username || null);
setDisplayName(response.display_name || null);
setOidcButtonLabel(response.oidc_button_label || null);
}, []);
// Check authentication on mount
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
const admin = response.is_admin || false;
const mode = response.auth_mode || 'none';
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
setIsAdmin(admin);
setAuthMode(mode);
applyAuthResponse(await checkAuth());
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
@@ -57,7 +64,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
}
};
verifyAuth();
}, []);
}, [applyAuthResponse]);
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
@@ -65,10 +72,8 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
try {
const response = await login(credentials);
if (response.success) {
// Re-check auth to get updated admin status from session
const authResponse = await checkAuth();
setIsAuthenticated(true);
setIsAdmin(authResponse.is_admin || false);
// Re-check auth to get updated session state
applyAuthResponse(await checkAuth());
setLoginError(null);
navigate('/', { replace: true });
} else {
@@ -83,7 +88,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
} finally {
setIsLoggingIn(false);
}
}, [navigate]);
}, [navigate, applyAuthResponse]);
const handleLogout = useCallback(async () => {
try {
@@ -107,6 +112,9 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
authChecked,
isAdmin,
authMode,
username,
displayName,
oidcButtonLabel,
loginError,
isLoggingIn,
setIsAuthenticated,

View File

@@ -7,9 +7,10 @@ interface LoginPageProps {
error: string | null;
isLoading: boolean;
authMode?: string;
oidcButtonLabel?: string | null;
}
export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProps) => {
export const LoginPage = ({ onLogin, error, isLoading, authMode, oidcButtonLabel }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
@@ -18,19 +19,18 @@ export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProp
style={{ backgroundColor: 'var(--background-color)', color: 'var(--text-color)' }}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src={logoUrl} alt="Logo" className="mx-auto mb-6 w-20 h-20" />
<h1 className="text-2xl font-semibold">Sign in to continue</h1>
</div>
<div
className="rounded-lg shadow-2xl p-8 border"
className="rounded-lg shadow-2xl p-6 border"
style={{
backgroundColor: 'var(--card-background)',
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} />
<div className="text-center mb-5">
<img src={logoUrl} alt="Logo" className="mx-auto w-12 h-12" />
</div>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} oidcButtonLabel={oidcButtonLabel} />
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import { getApiBase } from '../utils/basePath';
@@ -189,12 +189,9 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro
return transformMetadataToBook(response);
};
export const downloadBook = async (id: string, emailRecipient?: string): Promise<void> => {
export const downloadBook = async (id: string): Promise<void> => {
const params = new URLSearchParams();
params.set('id', id);
if (emailRecipient) {
params.set('email_recipient', emailRecipient);
}
await fetchJSON(`${API.download}?${params.toString()}`);
};
@@ -219,7 +216,6 @@ export const downloadRelease = async (release: {
series_position?: number;
subtitle?: string;
search_author?: string;
email_recipient?: string;
}): Promise<void> => {
await fetchJSON(`${API_BASE}/releases/download`, {
method: 'POST',
@@ -266,6 +262,10 @@ export const getSettings = async (): Promise<SettingsResponse> => {
return fetchJSON<SettingsResponse>(API.settings);
};
export const getSettingsTab = async (tabName: string): Promise<SettingsTab> => {
return fetchJSON<SettingsTab>(`${API.settings}/${tabName}`);
};
export const updateSettings = async (
tabName: string,
values: Record<string, unknown>
@@ -382,14 +382,27 @@ export const getReleases = async (
// Admin user management API
export type AdminAuthSource = 'builtin' | 'oidc' | 'proxy' | 'cwa';
export interface AdminUserEditCapabilities {
authSource: AdminAuthSource;
canSetPassword: boolean;
canEditRole: boolean;
canEditEmail: boolean;
canEditDisplayName: boolean;
}
export interface AdminUser {
id: number;
username: string;
email: string | null;
display_name: string | null;
role: string;
auth_source: AdminAuthSource;
is_active: boolean;
oidc_subject: string | null;
created_at: string;
edit_capabilities: AdminUserEditCapabilities;
settings?: Record<string, unknown>;
}
@@ -429,12 +442,27 @@ export const deleteAdminUser = async (userId: number): Promise<{ success: boolea
});
};
export interface CwaUserSyncResult {
success: boolean;
message: string;
created: number;
updated: number;
total: number;
}
export const syncAdminCwaUsers = async (): Promise<CwaUserSyncResult> => {
return fetchJSON<CwaUserSyncResult>(`${API_BASE}/admin/users/sync-cwa`, {
method: 'POST',
});
};
export interface DownloadDefaults {
BOOKS_OUTPUT_MODE: string;
DESTINATION: string;
DESTINATION_AUDIOBOOK: string;
BOOKLORE_LIBRARY_ID: string;
BOOKLORE_PATH_ID: string;
EMAIL_RECIPIENTS: Array<{ nickname: string; email: string }>;
EMAIL_RECIPIENT: string;
OIDC_ADMIN_GROUP: string;
OIDC_USE_ADMIN_GROUP: boolean;
OIDC_AUTO_PROVISION: boolean;
@@ -458,3 +486,40 @@ export interface BookloreOptions {
export const getBookloreOptions = async (): Promise<BookloreOptions> => {
return fetchJSON<BookloreOptions>(`${API_BASE}/admin/booklore-options`);
};
export interface DeliveryPreferencesResponse {
tab: string;
keys: string[];
fields: import('../types/settings').SettingsField[];
globalValues: Record<string, unknown>;
userOverrides: Record<string, unknown>;
effective: Record<string, { value: unknown; source: string }>;
}
export const getAdminDeliveryPreferences = async (
userId: number
): Promise<DeliveryPreferencesResponse> => {
return fetchJSON<DeliveryPreferencesResponse>(`${API_BASE}/admin/users/${userId}/delivery-preferences`);
};
export interface SettingsOverrideUserDetail {
userId: number;
username: string;
value: unknown;
}
export interface SettingsOverrideKeySummary {
count: number;
users: SettingsOverrideUserDetail[];
}
export interface SettingsOverridesSummaryResponse {
tab: string;
keys: Record<string, SettingsOverrideKeySummary>;
}
export const getAdminSettingsOverridesSummary = async (
tabName: string
): Promise<SettingsOverridesSummaryResponse> => {
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
};

View File

@@ -152,11 +152,6 @@ export type ContentType = 'ebook' | 'audiobook';
export type BooksOutputMode = 'folder' | 'booklore' | 'email';
export interface EmailRecipient {
nickname: string;
email: string;
}
export interface AppConfig {
calibre_web_url: string;
audiobook_library_url: string;
@@ -172,7 +167,6 @@ export interface AppConfig {
metadata_search_fields: MetadataSearchField[];
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
books_output_mode: BooksOutputMode;
email_recipients: EmailRecipient[];
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
download_to_browser: boolean; // Auto-download completed files to browser
settings_enabled: boolean; // Whether config directory is mounted and writable
@@ -194,8 +188,11 @@ export interface AuthResponse {
auth_required?: boolean;
auth_mode?: string;
is_admin?: boolean;
username?: string;
display_name?: string | null;
error?: string;
logout_url?: string;
oidc_button_label?: string;
}
// Type guard to check if a book is from a metadata provider

View File

@@ -49,6 +49,7 @@ export interface BaseField {
showWhen?: ShowWhen; // Conditional visibility based on another field's value
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
requiresRestart?: boolean; // True if changing this setting requires a container restart
userOverridable?: boolean; // True when this field supports per-user overrides
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
}

View File

@@ -2,16 +2,18 @@
Tests for security configuration and migration.
Tests the security settings registration, migration from old settings,
and proxy authentication configuration.
and builtin credential handling/synchronization.
"""
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from werkzeug.security import check_password_hash
from shelfmark.core.user_db import UserDB
@pytest.fixture
@@ -33,163 +35,219 @@ def mock_logger():
class TestSecurityMigration:
"""Tests for migrating legacy security settings."""
def test_migrate_use_cwa_auth_true(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=True to AUTH_METHOD='cwa'."""
# Create legacy config
def test_migrate_use_cwa_auth_true_syncs_legacy_admin(self, temp_config_dir, mock_logger, monkeypatch):
"""USE_CWA_AUTH=True migrates to cwa and keeps legacy creds synced to users DB."""
config_root = temp_config_dir.parent
monkeypatch.setenv("CONFIG_DIR", str(config_root))
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": True,
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
"BUILTIN_PASSWORD_HASH": "hashed_password",
}
config_file.write_text(json.dumps(legacy_config, indent=2))
# Mock load_config_file to return our test config, and the paths
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
# Verify migration - read the actual file
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "cwa"
assert "USE_CWA_AUTH" not in migrated_config
migrated = json.loads(config_file.read_text())
assert migrated["AUTH_METHOD"] == "cwa"
assert "USE_CWA_AUTH" not in migrated
assert migrated["BUILTIN_USERNAME"] == "admin"
assert migrated["BUILTIN_PASSWORD_HASH"] == "hashed_password"
user_db = UserDB(str(config_root / "users.db"))
user_db.initialize()
user = user_db.get_user(username="admin")
assert user is not None
assert user["role"] == "admin"
assert user["auth_source"] == "builtin"
assert user["password_hash"] == "hashed_password"
def test_migrate_use_cwa_auth_false_with_credentials(self, temp_config_dir, mock_logger, monkeypatch):
"""USE_CWA_AUTH=False with creds migrates to builtin and syncs users DB."""
config_root = temp_config_dir.parent
monkeypatch.setenv("CONFIG_DIR", str(config_root))
def test_migrate_use_cwa_auth_false_with_credentials(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=False with credentials to AUTH_METHOD='builtin'."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": False,
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
"BUILTIN_PASSWORD_HASH": "hashed_password",
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "builtin"
assert "USE_CWA_AUTH" not in migrated_config
migrated = json.loads(config_file.read_text())
assert migrated["AUTH_METHOD"] == "builtin"
assert "USE_CWA_AUTH" not in migrated
assert migrated["BUILTIN_USERNAME"] == "admin"
assert migrated["BUILTIN_PASSWORD_HASH"] == "hashed_password"
user_db = UserDB(str(config_root / "users.db"))
user_db.initialize()
user = user_db.get_user(username="admin")
assert user is not None
assert user["role"] == "admin"
def test_migrate_use_cwa_auth_false_without_credentials(self, temp_config_dir, mock_logger):
"""Test migrating USE_CWA_AUTH=False without credentials to AUTH_METHOD='none'."""
"""USE_CWA_AUTH=False without creds migrates to none."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": False
}
legacy_config = {"USE_CWA_AUTH": False}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "none"
assert "USE_CWA_AUTH" not in migrated_config
migrated = json.loads(config_file.read_text())
assert migrated["AUTH_METHOD"] == "none"
assert "USE_CWA_AUTH" not in migrated
def test_migrate_restrict_settings_to_admin(self, temp_config_dir, mock_logger):
"""Test migrating RESTRICT_SETTINGS_TO_ADMIN to CWA_RESTRICT_SETTINGS_TO_ADMIN."""
"""Legacy settings restriction should migrate to users tab global toggle."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"AUTH_METHOD": "cwa",
"RESTRICT_SETTINGS_TO_ADMIN": True
"RESTRICT_SETTINGS_TO_ADMIN": True,
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
def _load_config(tab_name: str):
if tab_name == "security":
return legacy_config.copy()
if tab_name == "users":
return {}
return {}
migrated_config = json.loads(config_file.read_text())
assert migrated_config["CWA_RESTRICT_SETTINGS_TO_ADMIN"] is True
assert "RESTRICT_SETTINGS_TO_ADMIN" not in migrated_config
with patch("shelfmark.config.security.load_config_file", side_effect=_load_config):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.core.settings_registry.save_config_file") as mock_save_config:
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated = json.loads(config_file.read_text())
assert "RESTRICT_SETTINGS_TO_ADMIN" not in migrated
mock_save_config.assert_called_with("users", {"RESTRICT_SETTINGS_TO_ADMIN": True})
def test_migrate_proxy_restriction_to_users_global(self, temp_config_dir, mock_logger):
"""Proxy-specific restriction should migrate to users.RESTRICT_SETTINGS_TO_ADMIN."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"AUTH_METHOD": "proxy",
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": False,
}
config_file.write_text(json.dumps(legacy_config, indent=2))
def _load_config(tab_name: str):
if tab_name == "security":
return legacy_config.copy()
if tab_name == "users":
return {}
return {}
with patch("shelfmark.config.security.load_config_file", side_effect=_load_config):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.core.settings_registry.save_config_file") as mock_save_config:
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated = json.loads(config_file.read_text())
assert "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" not in migrated
mock_save_config.assert_called_with("users", {"RESTRICT_SETTINGS_TO_ADMIN": False})
def test_migrate_preserves_existing_auth_method(self, temp_config_dir, mock_logger):
"""Test that existing AUTH_METHOD is not overwritten during migration."""
"""Existing AUTH_METHOD should not be overwritten."""
config_file = temp_config_dir / "config.json"
legacy_config = {
"USE_CWA_AUTH": True,
"AUTH_METHOD": "proxy" # Already has new format
"AUTH_METHOD": "proxy",
}
config_file.write_text(json.dumps(legacy_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=legacy_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
with patch("shelfmark.config.security.load_config_file", return_value=legacy_config.copy()):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
migrated_config = json.loads(config_file.read_text())
assert migrated_config["AUTH_METHOD"] == "proxy" # Should not change
assert "USE_CWA_AUTH" not in migrated_config
migrated = json.loads(config_file.read_text())
assert migrated["AUTH_METHOD"] == "proxy"
assert "USE_CWA_AUTH" not in migrated
def test_migrate_handles_missing_config_file(self, temp_config_dir, mock_logger):
"""Test that migration handles missing config file gracefully."""
with patch('shelfmark.config.security.load_config_file', side_effect=FileNotFoundError()):
with patch('shelfmark.config.security.logger', mock_logger):
def test_migrate_handles_missing_config_file(self, mock_logger):
"""Missing config file should be handled gracefully."""
with patch("shelfmark.config.security.load_config_file", side_effect=FileNotFoundError()):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
mock_logger.debug.assert_any_call("No existing security config file found - nothing to migrate")
def test_migrate_no_changes_needed(self, temp_config_dir, mock_logger):
"""Test migration when no changes are needed."""
"""No-op migration should not rewrite config."""
config_file = temp_config_dir / "config.json"
modern_config = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
"BUILTIN_PASSWORD_HASH": "hashed_password",
}
config_file.write_text(json.dumps(modern_config, indent=2))
with patch('shelfmark.config.security.load_config_file', return_value=modern_config.copy()):
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.logger', mock_logger):
with patch("shelfmark.config.security.load_config_file", return_value=modern_config.copy()):
with patch("shelfmark.core.settings_registry._get_config_file_path", return_value=str(config_file)):
with patch("shelfmark.core.settings_registry._ensure_config_dir"):
with patch("shelfmark.config.security.logger", mock_logger):
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
# Config should remain unchanged
final_config = json.loads(config_file.read_text())
# File won't have been rewritten, so it should be the original
assert final_config == modern_config
mock_logger.debug.assert_any_call("No security settings migration needed")
class TestSecuritySettings:
"""Tests for security settings registration."""
def test_security_settings_without_cwa(self):
"""Test that CWA option is not available when DB is not mounted."""
# Patch CWA_DB_PATH where it's imported in the function
with patch('shelfmark.config.env.CWA_DB_PATH', None):
# Need to reload the module to pick up the patch
"""CWA option should be hidden when DB is unavailable."""
with patch("shelfmark.config.env.CWA_DB_PATH", None):
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
from shelfmark.config.security import security_settings
fields = security_settings()
# Find the AUTH_METHOD field
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
assert auth_method_field is not None
# CWA should not be in options
option_values = [opt["value"] for opt in auth_method_field.options]
assert "none" in option_values
assert "builtin" in option_values
@@ -197,170 +255,194 @@ class TestSecuritySettings:
assert "cwa" not in option_values
def test_security_settings_with_cwa(self):
"""Test that CWA option is available when DB is mounted."""
# Create a mock path that exists
"""CWA option should be shown when DB is mounted."""
mock_path = MagicMock()
mock_path.exists.return_value = True
with patch('shelfmark.config.env.CWA_DB_PATH', mock_path):
with patch("shelfmark.config.env.CWA_DB_PATH", mock_path):
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
from shelfmark.config.security import security_settings
fields = security_settings()
# Find the AUTH_METHOD field
auth_method_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
assert auth_method_field is not None
# CWA should be in options
option_values = [opt["value"] for opt in auth_method_field.options]
assert "cwa" in option_values
def test_proxy_auth_fields_present(self):
"""Test that proxy auth configuration fields are present."""
def test_builtin_credential_fields_hidden(self):
"""Builtin username/password fields should be removed from settings UI."""
from shelfmark.config.security import security_settings
fields = security_settings()
field_keys = [f.key for f in fields]
# Verify proxy auth fields exist
assert "PROXY_AUTH_USER_HEADER" in field_keys
assert "PROXY_AUTH_LOGOUT_URL" in field_keys
assert "PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN" in field_keys
assert "PROXY_AUTH_ADMIN_GROUP_HEADER" in field_keys
assert "PROXY_AUTH_ADMIN_GROUP_NAME" in field_keys
def test_cwa_restrict_settings_field_present(self):
"""Test that CWA restrict settings field is present."""
assert "BUILTIN_USERNAME" not in field_keys
assert "BUILTIN_PASSWORD" not in field_keys
assert "BUILTIN_PASSWORD_CONFIRM" not in field_keys
def test_builtin_notice_field_removed(self):
"""Builtin guidance should be handled by the action button only."""
from shelfmark.config.security import security_settings
fields = security_settings()
field_keys = [f.key for f in fields]
assert "CWA_RESTRICT_SETTINGS_TO_ADMIN" in field_keys
notice = next((f for f in fields if f.key == "builtin_auth_notice"), None)
assert notice is None
def test_builtin_option_label_is_local(self):
"""Builtin auth option should be labeled Local."""
from shelfmark.config.security import security_settings
fields = security_settings()
auth_field = next((f for f in fields if f.key == "AUTH_METHOD"), None)
builtin_option = next((opt for opt in auth_field.options if opt["value"] == "builtin"), None)
assert builtin_option is not None
assert builtin_option["label"] == "Local"
def test_builtin_users_navigation_action_present(self):
"""Builtin mode should include an action button to open Users tab."""
from shelfmark.config.security import security_settings
fields = security_settings()
action = next((f for f in fields if f.key == "open_users_tab"), None)
assert action is not None
assert action.label == "Go to Users"
assert action.show_when == {"field": "AUTH_METHOD", "value": "builtin"}
class TestPasswordValidation:
"""Tests for password validation in the on_save handler."""
def test_on_save_validates_password_match(self):
"""Test that passwords must match."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "different_password"
"BUILTIN_PASSWORD_CONFIRM": "different_password",
}
result = _on_save_security(values)
assert result["error"] is True
assert "do not match" in result["message"]
def test_on_save_validates_password_length(self):
"""Test that password must be at least 4 characters."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "abc",
"BUILTIN_PASSWORD_CONFIRM": "abc"
"BUILTIN_PASSWORD_CONFIRM": "abc",
}
result = _on_save_security(values)
assert result["error"] is True
assert "at least 4 characters" in result["message"]
def test_on_save_requires_username_with_password(self):
"""Test that username is required when password is set."""
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "password123"
"BUILTIN_PASSWORD_CONFIRM": "password123",
}
result = _on_save_security(values)
assert result["error"] is True
assert "Username cannot be empty" in result["message"]
def test_on_save_hashes_password(self):
"""Test that password is properly hashed."""
def test_on_save_hashes_password(self, tmp_path, monkeypatch):
from shelfmark.config.security import _on_save_security
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "password123"
"BUILTIN_PASSWORD_CONFIRM": "password123",
}
result = _on_save_security(values)
assert result["error"] is False
assert "BUILTIN_PASSWORD_HASH" in result["values"]
assert "BUILTIN_PASSWORD" not in result["values"]
assert "BUILTIN_PASSWORD_CONFIRM" not in result["values"]
# Hash should be different from raw password
assert result["values"]["BUILTIN_PASSWORD_HASH"] != "password123"
def test_on_save_preserves_existing_hash_when_no_password(self):
"""Test that existing password hash is preserved when password fields are empty."""
from shelfmark.config.security import _on_save_security
with patch('shelfmark.config.security.load_config_file') as mock_load:
mock_load.return_value = {
"BUILTIN_PASSWORD_HASH": "existing_hash"
}
with patch("shelfmark.config.security.load_config_file") as mock_load:
mock_load.return_value = {"BUILTIN_PASSWORD_HASH": "existing_hash"}
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin"
"BUILTIN_USERNAME": "admin",
}
result = _on_save_security(values)
assert result["error"] is False
assert result["values"]["BUILTIN_PASSWORD_HASH"] == "existing_hash"
class TestClearCredentials:
"""Tests for clearing built-in credentials."""
class TestBuiltinAdminSync:
"""Builtin credential save should create/update a local admin user."""
def test_clear_credentials_removes_username_and_hash(self, temp_config_dir):
"""Test that clearing credentials removes username and password hash."""
config_file = temp_config_dir / "config.json"
config = {
@pytest.fixture(autouse=True)
def setup_user_db(self, tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
self.user_db = UserDB(str(tmp_path / "users.db"))
self.user_db.initialize()
def test_on_save_builtin_creates_local_admin(self):
from shelfmark.config.security import _on_save_security
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password"
"BUILTIN_PASSWORD": "password123",
"BUILTIN_PASSWORD_CONFIRM": "password123",
}
config_file.write_text(json.dumps(config, indent=2))
with patch('shelfmark.core.settings_registry._get_config_file_path', return_value=str(config_file)):
with patch('shelfmark.core.settings_registry._ensure_config_dir'):
with patch('shelfmark.config.security.load_config_file', return_value=config.copy()):
from shelfmark.config.security import _clear_builtin_credentials
result = _clear_builtin_credentials()
result = _on_save_security(values)
assert result["success"] is True
cleared_config = json.loads(config_file.read_text())
assert "BUILTIN_USERNAME" not in cleared_config
assert "BUILTIN_PASSWORD_HASH" not in cleared_config
assert result["error"] is False
user = self.user_db.get_user(username="admin")
assert user is not None
assert user["role"] == "admin"
assert user["auth_source"] == "builtin"
assert check_password_hash(user["password_hash"], "password123")
def test_clear_credentials_handles_errors(self):
"""Test that clearing credentials handles errors gracefully."""
with patch('shelfmark.config.security.load_config_file', side_effect=Exception("Test error")):
from shelfmark.config.security import _clear_builtin_credentials
result = _clear_builtin_credentials()
def test_on_save_builtin_updates_existing_user(self):
from shelfmark.config.security import _on_save_security
assert result["success"] is False
assert "Test error" in result["message"]
existing = self.user_db.create_user(username="admin", role="user")
assert existing["role"] == "user"
values = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD": "newpassword",
"BUILTIN_PASSWORD_CONFIRM": "newpassword",
}
result = _on_save_security(values)
assert result["error"] is False
user = self.user_db.get_user(username="admin")
assert user is not None
assert user["role"] == "admin"
assert user["auth_source"] == "builtin"
assert check_password_hash(user["password_hash"], "newpassword")

View File

@@ -5,6 +5,7 @@ Tests CRUD endpoints for managing users from the admin panel.
"""
import os
import sqlite3
import tempfile
from unittest.mock import patch
@@ -105,10 +106,61 @@ class TestAdminUsersListEndpoint:
users = resp.json
assert "password_hash" not in users[0]
def test_list_users_includes_auth_source_and_is_active(self, admin_client, user_db):
user_db.create_user(username="local_user", auth_source="builtin")
user_db.create_user(
username="oidc_user",
oidc_subject="oidc-sub-123",
auth_source="oidc",
)
user_db.create_user(username="proxy_user", auth_source="proxy")
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="builtin"):
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
by_username = {u["username"]: u for u in resp.json}
assert by_username["local_user"]["auth_source"] == "builtin"
assert by_username["local_user"]["is_active"] is True
assert by_username["local_user"]["edit_capabilities"]["canSetPassword"] is True
assert by_username["local_user"]["edit_capabilities"]["canEditRole"] is True
assert by_username["local_user"]["edit_capabilities"]["canEditEmail"] is True
assert by_username["oidc_user"]["auth_source"] == "oidc"
assert by_username["oidc_user"]["is_active"] is False
assert by_username["oidc_user"]["edit_capabilities"]["canSetPassword"] is False
assert by_username["oidc_user"]["edit_capabilities"]["canEditRole"] is False
assert by_username["oidc_user"]["edit_capabilities"]["canEditEmail"] is False
assert by_username["oidc_user"]["edit_capabilities"]["canEditDisplayName"] is False
assert by_username["proxy_user"]["auth_source"] == "proxy"
assert by_username["proxy_user"]["is_active"] is False
assert by_username["proxy_user"]["edit_capabilities"]["canSetPassword"] is False
assert by_username["proxy_user"]["edit_capabilities"]["canEditRole"] is False
assert by_username["proxy_user"]["edit_capabilities"]["canEditEmail"] is True
def test_list_users_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/users")
assert resp.status_code == 403
def test_list_users_oidc_role_editable_when_group_auth_disabled(self, admin_client, user_db):
user_db.create_user(
username="oidc_user",
oidc_subject="oidc-sub-123",
auth_source="oidc",
)
with patch(
"shelfmark.core.admin_routes.load_config_file",
return_value={"OIDC_USE_ADMIN_GROUP": False},
):
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
oidc_user = next(u for u in resp.json if u["username"] == "oidc_user")
assert oidc_user["edit_capabilities"]["canEditRole"] is True
def test_list_users_no_session_allows_access_in_no_auth(self, no_session_client):
"""No session + no-auth mode = admin access allowed."""
resp = no_session_client.get("/api/admin/users")
@@ -273,6 +325,36 @@ class TestAdminUserCreateEndpoint:
assert resp.status_code == 201
assert resp.json["role"] == "user"
def test_create_user_rejected_in_proxy_mode(self, admin_client):
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="proxy"):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 400
assert "Local user creation is disabled" in resp.json["error"]
def test_create_user_rejected_in_cwa_mode(self, admin_client):
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="cwa"):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 400
assert "Local user creation is disabled" in resp.json["error"]
def test_create_user_allowed_in_oidc_mode(self, admin_client):
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="oidc"):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 201
assert resp.json["username"] == "alice"
# ---------------------------------------------------------------------------
# GET /api/admin/users/<id>
@@ -292,10 +374,10 @@ class TestAdminUserGetEndpoint:
def test_get_user_includes_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"booklore_library_id": 5})
user_db.set_user_settings(user["id"], {"BOOKLORE_LIBRARY_ID": 5})
resp = admin_client.get(f"/api/admin/users/{user['id']}")
assert resp.json["settings"]["booklore_library_id"] == 5
assert resp.json["settings"]["BOOKLORE_LIBRARY_ID"] == 5
def test_get_user_empty_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
@@ -375,27 +457,38 @@ class TestAdminUserUpdateEndpoint:
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"booklore_library_id": 3}},
json={"settings": {"BOOKLORE_LIBRARY_ID": 3}},
)
assert resp.status_code == 200
settings = user_db.get_user_settings(user["id"])
assert settings["booklore_library_id"] == 3
assert settings["BOOKLORE_LIBRARY_ID"] == 3
def test_update_settings_merges(self, admin_client, user_db):
def test_update_user_settings_accepts_audiobook_destination(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"existing_key": "keep"})
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"new_key": "added"}},
json={"settings": {"DESTINATION_AUDIOBOOK": "/audiobooks/alice"}},
)
assert resp.status_code == 200
assert resp.json["settings"]["existing_key"] == "keep"
assert resp.json["settings"]["new_key"] == "added"
settings = user_db.get_user_settings(user["id"])
assert settings["DESTINATION_AUDIOBOOK"] == "/audiobooks/alice"
def test_update_settings_merges(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"BOOKLORE_LIBRARY_ID": "2"}},
)
assert resp.status_code == 200
assert resp.json["settings"]["DESTINATION"] == "/books/alice"
assert resp.json["settings"]["BOOKLORE_LIBRARY_ID"] == "2"
def test_update_response_includes_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"theme": "dark"})
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
@@ -403,7 +496,40 @@ class TestAdminUserUpdateEndpoint:
)
assert resp.status_code == 200
assert "settings" in resp.json
assert resp.json["settings"]["theme"] == "dark"
assert resp.json["settings"]["DESTINATION"] == "/books/alice"
def test_update_user_settings_rejects_unknown_key(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"UNKNOWN_SETTING": "value"}},
)
assert resp.status_code == 400
assert resp.json["error"] == "Invalid settings payload"
assert any("Unknown setting: UNKNOWN_SETTING" in msg for msg in resp.json["details"])
def test_update_user_settings_rejects_non_overridable_key(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"FILE_ORGANIZATION": "rename"}},
)
assert resp.status_code == 400
assert resp.json["error"] == "Invalid settings payload"
assert any("Setting not user-overridable: FILE_ORGANIZATION" in msg for msg in resp.json["details"])
def test_update_user_settings_rejects_lowercase_key(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"destination": "/books/alice"}},
)
assert resp.status_code == 400
assert resp.json["error"] == "Invalid settings payload"
assert any("Unknown setting: destination" in msg for msg in resp.json["details"])
def test_update_response_excludes_password_hash(self, admin_client, user_db):
user = user_db.create_user(username="alice", password_hash="secret")
@@ -429,6 +555,59 @@ class TestAdminUserUpdateEndpoint:
)
assert resp.status_code == 403
def test_update_proxy_role_rejected(self, admin_client, user_db):
user = user_db.create_user(username="proxyuser", role="user", auth_source="proxy")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin"},
)
assert resp.status_code == 400
assert "Cannot change role for PROXY users" in resp.json["error"]
def test_update_proxy_role_noop_allowed(self, admin_client, user_db):
user = user_db.create_user(username="proxyuser", role="user", auth_source="proxy")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "user", "display_name": "Proxy User"},
)
assert resp.status_code == 200
assert resp.json["display_name"] == "Proxy User"
def test_update_cwa_email_rejected(self, admin_client, user_db):
user = user_db.create_user(
username="cwauser",
email="old@example.com",
auth_source="cwa",
)
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"email": "new@example.com"},
)
assert resp.status_code == 400
assert "Cannot change email for CWA users" in resp.json["error"]
def test_update_oidc_email_rejected(self, admin_client, user_db):
user = user_db.create_user(
username="oidcuser",
email="old@example.com",
oidc_subject="sub-oidc-1",
auth_source="oidc",
)
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"email": "new@example.com"},
)
assert resp.status_code == 400
assert "Cannot change email for OIDC users" in resp.json["error"]
# ---------------------------------------------------------------------------
# PUT /api/admin/users/<id> — password update
@@ -502,6 +681,117 @@ class TestAdminUserPasswordUpdate:
assert "password_hash" not in resp.json
assert "password" not in resp.json
def test_update_password_rejected_for_proxy_user(self, admin_client, user_db):
user = user_db.create_user(username="proxyuser", auth_source="proxy")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "newpass99"},
)
assert resp.status_code == 400
assert "Cannot set password for PROXY users" in resp.json["error"]
# ---------------------------------------------------------------------------
# POST /api/admin/users/sync-cwa
# ---------------------------------------------------------------------------
class TestAdminSyncCwaUsersEndpoint:
"""Tests for POST /api/admin/users/sync-cwa."""
def test_sync_cwa_users_links_by_email_and_avoids_username_overwrite(
self,
admin_client,
user_db,
tmp_path,
):
cwa_db_path = tmp_path / "app.db"
conn = sqlite3.connect(cwa_db_path)
try:
conn.execute(
"""
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
role INTEGER,
email TEXT
)
"""
)
conn.executemany(
"INSERT INTO user (name, role, email) VALUES (?, ?, ?)",
[
("alice", 1, "alice@example.com"),
("bob", 0, "bob@example.com"),
(" ", 1, "skip@example.com"),
],
)
conn.commit()
finally:
conn.close()
local_email_match = user_db.create_user(
username="alice_local",
email="alice@example.com",
role="user",
auth_source="builtin",
)
local_username_collision = user_db.create_user(
username="bob",
email="old@example.com",
role="admin",
auth_source="builtin",
)
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="cwa"):
with patch("shelfmark.core.admin_routes.CWA_DB_PATH", cwa_db_path):
resp = admin_client.post("/api/admin/users/sync-cwa")
assert resp.status_code == 200
assert resp.json["success"] is True
assert resp.json["created"] == 1
assert resp.json["updated"] == 1
assert resp.json["total"] == 2
alice_linked = user_db.get_user(user_id=local_email_match["id"])
assert alice_linked is not None
assert alice_linked["username"] == "alice_local"
assert alice_linked["auth_source"] == "cwa"
assert alice_linked["role"] == "admin"
assert alice_linked["email"] == "alice@example.com"
bob_original = user_db.get_user(user_id=local_username_collision["id"])
assert bob_original is not None
assert bob_original["username"] == "bob"
assert bob_original["auth_source"] == "builtin"
assert bob_original["role"] == "admin"
assert bob_original["email"] == "old@example.com"
bob_cwa = next(
user for user in user_db.list_users()
if user.get("auth_source") == "cwa" and user.get("email") == "bob@example.com"
)
assert bob_cwa["username"].startswith("bob__cwa")
assert bob_cwa["role"] == "user"
def test_sync_cwa_users_rejected_when_not_in_cwa_mode(self, admin_client):
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="builtin"):
resp = admin_client.post("/api/admin/users/sync-cwa")
assert resp.status_code == 400
assert "only available" in resp.json["error"]
def test_sync_cwa_users_returns_503_when_db_unavailable(self, admin_client, tmp_path):
missing_db_path = tmp_path / "missing.db"
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="cwa"):
with patch("shelfmark.core.admin_routes.CWA_DB_PATH", missing_db_path):
resp = admin_client.post("/api/admin/users/sync-cwa")
assert resp.status_code == 503
assert "not available" in resp.json["error"]
# ---------------------------------------------------------------------------
# GET /api/admin/download-defaults
@@ -525,9 +815,10 @@ class TestAdminDownloadDefaults:
config = {
"BOOKS_OUTPUT_MODE": "folder",
"DESTINATION": "/books",
"DESTINATION_AUDIOBOOK": "/audiobooks",
"BOOKLORE_LIBRARY_ID": "2",
"BOOKLORE_PATH_ID": "5",
"EMAIL_RECIPIENTS": [{"nickname": "kindle", "email": "me@kindle.com"}],
"EMAIL_RECIPIENT": "reader@example.com",
}
(plugins_dir / "downloads.json").write_text(json.dumps(config))
@@ -537,9 +828,10 @@ class TestAdminDownloadDefaults:
data = resp.json
assert data["BOOKS_OUTPUT_MODE"] == "folder"
assert data["DESTINATION"] == "/books"
assert data["DESTINATION_AUDIOBOOK"] == "/audiobooks"
assert data["BOOKLORE_LIBRARY_ID"] == "2"
assert data["BOOKLORE_PATH_ID"] == "5"
assert data["EMAIL_RECIPIENTS"] == [{"nickname": "kindle", "email": "me@kindle.com"}]
assert data["EMAIL_RECIPIENT"] == "reader@example.com"
def test_returns_defaults_when_no_config(self, admin_client, tmp_path):
"""If no downloads config file exists, return sensible defaults."""
@@ -553,6 +845,7 @@ class TestAdminDownloadDefaults:
data = resp.json
assert "BOOKS_OUTPUT_MODE" in data
assert "DESTINATION" in data
assert "DESTINATION_AUDIOBOOK" in data
def test_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/download-defaults")
@@ -599,6 +892,208 @@ class TestAdminBookloreOptions:
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/admin/users/<id>/delivery-preferences
# ---------------------------------------------------------------------------
class TestAdminDeliveryPreferences:
"""Tests for GET /api/admin/users/<id>/delivery-preferences."""
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
downloads_config = {
"BOOKS_OUTPUT_MODE": "folder",
"DESTINATION": "/books",
"DESTINATION_AUDIOBOOK": "/audiobooks",
"BOOKLORE_LIBRARY_ID": "7",
"BOOKLORE_PATH_ID": "21",
"EMAIL_RECIPIENT": "global@example.com",
}
(plugins_dir / "downloads.json").write_text(json.dumps(downloads_config))
from shelfmark.core.config import config as app_config
app_config.refresh()
def test_returns_curated_fields_and_effective_values(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(
user["id"],
{
"BOOKS_OUTPUT_MODE": "email",
"EMAIL_RECIPIENT": "alice@example.com",
"DESTINATION_AUDIOBOOK": "/audiobooks/alice",
},
)
resp = admin_client.get(f"/api/admin/users/{user['id']}/delivery-preferences")
assert resp.status_code == 200
data = resp.json
assert data["tab"] == "downloads"
assert data["keys"] == [
"BOOKS_OUTPUT_MODE",
"DESTINATION",
"BOOKLORE_LIBRARY_ID",
"BOOKLORE_PATH_ID",
"EMAIL_RECIPIENT",
"DESTINATION_AUDIOBOOK",
]
field_keys = [field["key"] for field in data["fields"]]
assert set(field_keys) == set(data["keys"])
assert data["userOverrides"]["BOOKS_OUTPUT_MODE"] == "email"
assert data["userOverrides"]["EMAIL_RECIPIENT"] == "alice@example.com"
assert data["userOverrides"]["DESTINATION_AUDIOBOOK"] == "/audiobooks/alice"
assert data["effective"]["BOOKS_OUTPUT_MODE"]["source"] == "user_override"
assert data["effective"]["BOOKS_OUTPUT_MODE"]["value"] == "email"
assert data["effective"]["DESTINATION"]["source"] in {"global_config", "env_var"}
assert data["effective"]["BOOKLORE_LIBRARY_ID"]["source"] == "global_config"
assert data["effective"]["BOOKLORE_LIBRARY_ID"]["value"] == "7"
assert data["effective"]["EMAIL_RECIPIENT"]["source"] == "user_override"
assert data["effective"]["EMAIL_RECIPIENT"]["value"] == "alice@example.com"
assert data["effective"]["DESTINATION_AUDIOBOOK"]["source"] == "user_override"
assert data["effective"]["DESTINATION_AUDIOBOOK"]["value"] == "/audiobooks/alice"
def test_returns_404_for_unknown_user(self, admin_client):
resp = admin_client.get("/api/admin/users/9999/delivery-preferences")
assert resp.status_code == 404
def test_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.get(f"/api/admin/users/{user['id']}/delivery-preferences")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/admin/settings/overrides-summary
# ---------------------------------------------------------------------------
class TestAdminOverridesSummary:
"""Tests for GET /api/admin/settings/overrides-summary."""
def test_returns_override_counts_for_downloads_tab(self, admin_client, user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
user_db.set_user_settings(
alice["id"],
{"BOOKS_OUTPUT_MODE": "folder", "DESTINATION": "/books/alice"},
)
user_db.set_user_settings(
bob["id"],
{
"BOOKS_OUTPUT_MODE": "email",
"DESTINATION": "/books/bob",
"EMAIL_RECIPIENT": "bob@example.com",
},
)
resp = admin_client.get("/api/admin/settings/overrides-summary?tab=downloads")
assert resp.status_code == 200
data = resp.json
assert data["tab"] == "downloads"
keys = data["keys"]
assert keys["BOOKS_OUTPUT_MODE"]["count"] == 2
assert keys["DESTINATION"]["count"] == 2
assert keys["EMAIL_RECIPIENT"]["count"] == 1
assert "BOOKLORE_LIBRARY_ID" not in keys
destination_users = {u["username"] for u in keys["DESTINATION"]["users"]}
assert destination_users == {"alice", "bob"}
email_users = keys["EMAIL_RECIPIENT"]["users"]
assert len(email_users) == 1
assert email_users[0]["username"] == "bob"
assert email_users[0]["value"] == "bob@example.com"
def test_returns_404_for_unknown_tab(self, admin_client):
resp = admin_client.get("/api/admin/settings/overrides-summary?tab=does-not-exist")
assert resp.status_code == 404
def test_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/settings/overrides-summary?tab=downloads")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/admin/users/<id>/effective-settings
# ---------------------------------------------------------------------------
class TestAdminEffectiveSettings:
"""Tests for GET /api/admin/users/<id>/effective-settings."""
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
downloads_config = {
"BOOKS_OUTPUT_MODE": "booklore",
"BOOKLORE_LIBRARY_ID": "7",
}
(plugins_dir / "downloads.json").write_text(json.dumps(downloads_config))
monkeypatch.setenv("INGEST_DIR", "/env/books")
# Ensure config singleton sees the current test env/config dir.
from shelfmark.core.config import config as app_config
app_config.refresh()
def test_returns_effective_values_with_sources(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(
user["id"],
{"EMAIL_RECIPIENT": "alice@kindle.com"},
)
resp = admin_client.get(f"/api/admin/users/{user['id']}/effective-settings")
assert resp.status_code == 200
data = resp.json
assert data["DESTINATION"]["value"] == "/env/books"
assert data["DESTINATION"]["source"] == "env_var"
assert data["BOOKLORE_LIBRARY_ID"]["value"] == "7"
assert data["BOOKLORE_LIBRARY_ID"]["source"] == "global_config"
assert data["BOOKLORE_PATH_ID"]["value"] in ("", None)
assert data["BOOKLORE_PATH_ID"]["source"] == "default"
assert data["EMAIL_RECIPIENT"]["value"] == "alice@kindle.com"
assert data["EMAIL_RECIPIENT"]["source"] == "user_override"
def test_returns_404_for_unknown_user(self, admin_client):
resp = admin_client.get("/api/admin/users/9999/effective-settings")
assert resp.status_code == 404
def test_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.get(f"/api/admin/users/{user['id']}/effective-settings")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/admin/users/<id>
# ---------------------------------------------------------------------------
@@ -634,6 +1129,37 @@ class TestAdminUserDeleteEndpoint:
assert len(resp.json) == 1
assert resp.json[0]["username"] == "bob"
def test_delete_active_proxy_user_rejected(self, admin_client, user_db):
user = user_db.create_user(username="proxyuser", auth_source="proxy")
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="proxy"):
resp = admin_client.delete(f"/api/admin/users/{user['id']}")
assert resp.status_code == 400
assert "Cannot delete active PROXY users" in resp.json["error"]
def test_delete_inactive_proxy_user_allowed(self, admin_client, user_db):
user = user_db.create_user(username="proxyuser", auth_source="proxy")
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="builtin"):
resp = admin_client.delete(f"/api/admin/users/{user['id']}")
assert resp.status_code == 200
assert resp.json["success"] is True
def test_delete_active_oidc_user_allowed_when_auto_provision_enabled(self, admin_client, user_db):
user = user_db.create_user(
username="oidcuser",
oidc_subject="sub-123",
auth_source="oidc",
)
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="oidc"):
resp = admin_client.delete(f"/api/admin/users/{user['id']}")
assert resp.status_code == 200
assert resp.json["success"] is True
# ---------------------------------------------------------------------------
# OIDC lockout prevention (security on_save handler)

View File

@@ -4,7 +4,7 @@ from shelfmark.download.outputs.booklore import build_booklore_config
class TestBuildBookloreConfigWithOverrides:
"""build_booklore_config should accept per-user library/path overrides."""
"""build_booklore_config should resolve per-user library/path via config."""
BASE_SETTINGS = {
"BOOKLORE_HOST": "http://booklore:6060",
@@ -14,77 +14,62 @@ class TestBuildBookloreConfigWithOverrides:
"BOOKLORE_PATH_ID": 10,
}
def test_global_config_no_overrides(self):
def test_global_config_without_user_context(self):
config = build_booklore_config(self.BASE_SETTINGS)
assert config.library_id == 1
assert config.path_id == 10
def test_override_library_and_path(self):
overrides = {"booklore_library_id": 2, "booklore_path_id": 20}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
def test_override_library_and_path_with_user_context(self, monkeypatch):
def fake_get(key, default=None, user_id=None):
if user_id == 7 and key == "BOOKLORE_LIBRARY_ID":
return 2
if user_id == 7 and key == "BOOKLORE_PATH_ID":
return 20
return default
monkeypatch.setattr("shelfmark.download.outputs.booklore.core_config.config.get", fake_get)
config = build_booklore_config(self.BASE_SETTINGS, user_id=7)
assert config.library_id == 2
assert config.path_id == 20
def test_override_library_only(self):
overrides = {"booklore_library_id": 3}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
def test_override_library_only(self, monkeypatch):
def fake_get(key, default=None, user_id=None):
if user_id == 7 and key == "BOOKLORE_LIBRARY_ID":
return 3
return default
monkeypatch.setattr("shelfmark.download.outputs.booklore.core_config.config.get", fake_get)
config = build_booklore_config(self.BASE_SETTINGS, user_id=7)
assert config.library_id == 3
assert config.path_id == 10 # falls back to global
def test_override_path_only(self):
overrides = {"booklore_path_id": 30}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
def test_override_path_only(self, monkeypatch):
def fake_get(key, default=None, user_id=None):
if user_id == 7 and key == "BOOKLORE_PATH_ID":
return 30
return default
monkeypatch.setattr("shelfmark.download.outputs.booklore.core_config.config.get", fake_get)
config = build_booklore_config(self.BASE_SETTINGS, user_id=7)
assert config.library_id == 1 # falls back to global
assert config.path_id == 30
def test_empty_overrides_uses_global(self):
config = build_booklore_config(self.BASE_SETTINGS, user_overrides={})
def test_none_user_context_uses_global(self):
config = build_booklore_config(self.BASE_SETTINGS, user_id=None)
assert config.library_id == 1
assert config.path_id == 10
def test_none_overrides_uses_global(self):
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=None)
assert config.library_id == 1
assert config.path_id == 10
def test_auth_fields_remain_global(self, monkeypatch):
"""Only Booklore library/path should be resolved with user context."""
def fake_get(key, default=None, user_id=None):
if user_id == 7 and key == "BOOKLORE_LIBRARY_ID":
return 5
if user_id == 7 and key == "BOOKLORE_PATH_ID":
return 15
return default
def test_auth_fields_not_overridable(self):
"""Auth stays global - user overrides should not affect host/user/pass."""
overrides = {
"booklore_library_id": 5,
"BOOKLORE_HOST": "http://evil:6060",
"BOOKLORE_USERNAME": "hacker",
}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
monkeypatch.setattr("shelfmark.download.outputs.booklore.core_config.config.get", fake_get)
config = build_booklore_config(self.BASE_SETTINGS, user_id=7)
assert config.base_url == "http://booklore:6060"
assert config.username == "admin"
assert config.library_id == 5
class TestOutputArgsForBooklore:
"""Download tasks should carry per-user booklore settings in output_args."""
def test_output_args_with_booklore_settings(self):
from shelfmark.core.models import DownloadTask
task = DownloadTask(
task_id="test-1",
source="direct_download",
title="Book1",
output_mode="booklore",
output_args={"booklore_library_id": 2, "booklore_path_id": 20},
user_id=1,
)
assert task.output_args["booklore_library_id"] == 2
assert task.output_args["booklore_path_id"] == 20
def test_output_args_empty_for_global_booklore(self):
from shelfmark.core.models import DownloadTask
task = DownloadTask(
task_id="test-2",
source="direct_download",
title="Book1",
output_mode="booklore",
output_args={},
)
assert task.output_args == {}

View File

@@ -0,0 +1,62 @@
"""Tests for Config.get per-user override precedence."""
from types import SimpleNamespace
from shelfmark.core.config import config
class _DummyField:
def __init__(self, env_supported: bool, user_overridable: bool):
self.env_supported = env_supported
self.user_overridable = user_overridable
def test_get_prefers_env_over_user_override(monkeypatch):
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"DESTINATION": "/env/books"})
monkeypatch.setattr(
config,
"_field_map",
{"DESTINATION": (_DummyField(env_supported=True, user_overridable=True), "downloads")},
)
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: True),
)
assert config.get("DESTINATION", "/default", user_id=10) == "/env/books"
def test_get_uses_user_override_when_not_env(monkeypatch):
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"DESTINATION": "/global/books"})
monkeypatch.setattr(
config,
"_field_map",
{"DESTINATION": (_DummyField(env_supported=True, user_overridable=True), "downloads")},
)
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: False),
)
assert config.get("DESTINATION", "/default", user_id=10) == "/user/books"
def test_get_ignores_user_override_for_non_overridable_field(monkeypatch):
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"FILE_ORGANIZATION": "rename"})
monkeypatch.setattr(
config,
"_field_map",
{"FILE_ORGANIZATION": (_DummyField(env_supported=True, user_overridable=False), "downloads")},
)
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "organize")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: False),
)
assert config.get("FILE_ORGANIZATION", "rename", user_id=10) == "rename"

View File

@@ -0,0 +1,95 @@
"""Tests for CWA user linking/provisioning helpers."""
import os
import tempfile
import pytest
from shelfmark.core.cwa_user_sync import upsert_cwa_user
from shelfmark.core.user_db import UserDB
@pytest.fixture
def user_db():
with tempfile.TemporaryDirectory() as tmpdir:
db = UserDB(os.path.join(tmpdir, "users.db"))
db.initialize()
yield db
def test_upsert_links_existing_user_by_unique_email(user_db):
existing = user_db.create_user(
username="local_admin",
email="admin@example.com",
role="admin",
auth_source="builtin",
)
user, action = upsert_cwa_user(
user_db,
cwa_username="admin",
cwa_email="admin@example.com",
role="user",
)
assert action == "updated"
assert user["id"] == existing["id"]
assert user["username"] == "local_admin"
assert user["auth_source"] == "cwa"
assert user["role"] == "user"
def test_upsert_creates_alias_when_username_taken_by_non_cwa(user_db):
local_user = user_db.create_user(
username="admin",
email="local@example.com",
role="admin",
auth_source="builtin",
)
user, action = upsert_cwa_user(
user_db,
cwa_username="admin",
cwa_email="cwa@example.com",
role="admin",
)
assert action == "created"
assert user["username"].startswith("admin__cwa")
assert user["auth_source"] == "cwa"
assert user["email"] == "cwa@example.com"
local_after = user_db.get_user(user_id=local_user["id"])
assert local_after is not None
assert local_after["username"] == "admin"
assert local_after["auth_source"] == "builtin"
assert local_after["email"] == "local@example.com"
def test_upsert_updates_existing_cwa_user_by_username_before_email(user_db):
cwa_user = user_db.create_user(
username="reader",
email="old@example.com",
role="user",
auth_source="cwa",
)
user_db.create_user(
username="other",
email="new@example.com",
role="user",
auth_source="builtin",
)
user, action = upsert_cwa_user(
user_db,
cwa_username="reader",
cwa_email="new@example.com",
role="admin",
)
assert action == "updated"
assert user["id"] == cwa_user["id"]
assert user["username"] == "reader"
assert user["auth_source"] == "cwa"
assert user["email"] == "new@example.com"
assert user["role"] == "admin"

View File

@@ -70,7 +70,7 @@ def _mock_destination_config(ingest_dir: Path, extra=None):
}
if extra:
values.update(extra)
return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
return MagicMock(side_effect=lambda key, default=None, **_kwargs: values.get(key, default))
def _sync_core_config(mock_config, mock_core_config, mock_archive_config=None):
@@ -238,7 +238,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -269,7 +269,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -295,7 +295,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -321,7 +321,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"], # PDF not supported
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -349,7 +349,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "rename",
}.get(key, default))
@@ -378,7 +378,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True # Ignored for multi-file
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -407,7 +407,7 @@ class TestProcessDirectory:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -435,7 +435,7 @@ class TestProcessDirectory:
patch('shelfmark.download.postprocess.transfer.atomic_move', side_effect=Exception("Move failed")):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -542,7 +542,7 @@ class TestPostProcessDownload:
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": str(library),
"FILE_ORGANIZATION": "organize",
"TEMPLATE_ORGANIZE": "{Author}/{Title}",
@@ -579,7 +579,7 @@ class TestPostProcessDownload:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": str(temp_dirs["ingest"]),
"FILE_ORGANIZATION": "none",
}.get(key, default))
@@ -651,7 +651,7 @@ class TestPostProcessDownload:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": str(temp_dirs["ingest"]),
"INGEST_DIR": str(temp_dirs["ingest"]),
"DESTINATION_AUDIOBOOK": str(audiobook_ingest),
@@ -781,7 +781,7 @@ class TestCustomScriptExecution:
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"BOOKS_OUTPUT_MODE": "booklore",
"BOOKLORE_HOST": "http://booklore:6060",
"BOOKLORE_USERNAME": "user",

View File

@@ -38,7 +38,7 @@ def _run_organize_post_process(
patch('shelfmark.download.postprocess.transfer.same_filesystem', return_value=same_fs):
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": str(library),
"FILE_ORGANIZATION": "organize",
"HARDLINK_TORRENTS": hardlink_enabled,
@@ -229,10 +229,15 @@ class TestAtomicHardlink:
source.write_text("content")
dest = tmp_path / "dest.txt"
def _raise_perm(*_args, **_kwargs):
raise PermissionError("hardlink not permitted")
original_link = os.link
monkeypatch.setattr(os, "link", _raise_perm)
def _raise_only_for_initial_link(src, dst, *_args, **_kwargs):
# Force hardlink -> copy fallback while allowing atomic_copy publish step.
if Path(src) == source:
raise PermissionError("hardlink not permitted")
return original_link(src, dst)
monkeypatch.setattr(os, "link", _raise_only_for_initial_link)
result = _atomic_hardlink(source, dest)
@@ -352,7 +357,7 @@ class TestHardlinkWithLibraryMode:
def mock_config(self):
"""Mock config for library mode."""
with patch('shelfmark.core.config.config') as mock:
mock.get = MagicMock(side_effect=lambda key, default=None: {
mock.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"LIBRARY_PATH": None,
"LIBRARY_PATH_AUDIOBOOK": None,
"LIBRARY_TEMPLATE": "{Author}/{Title}",
@@ -874,7 +879,7 @@ class TestTorrentSourceCleanupProtection:
def _make_config_mock(self, library_path: str, hardlink: bool = True):
"""Create config mock for library/organize mode with hardlinking."""
return MagicMock(side_effect=lambda key, default=None: {
return MagicMock(side_effect=lambda key, default=None, **_kwargs: {
# Destination paths (what _get_final_destination uses)
"DESTINATION": library_path,
"DESTINATION_AUDIOBOOK": library_path,
@@ -1349,7 +1354,7 @@ class TestEdgeCases:
status_cb = MagicMock()
with patch('shelfmark.core.config.config') as mock_config:
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": str(library),
"TEMPLATE_ORGANIZE": "{Title}",
"FILE_ORGANIZATION": "organize",
@@ -1384,7 +1389,7 @@ class TestEdgeCases:
status_cb = MagicMock()
with patch('shelfmark.core.config.config') as mock_config:
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: {
"DESTINATION": "/nonexistent/protected/path",
"TEMPLATE_ORGANIZE": "{Title}",
"FILE_ORGANIZATION": "organize",

View File

@@ -169,6 +169,7 @@ class TestProvisionOIDCUser:
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["username"] == "john"
assert user["oidc_subject"] == "sub-123"
assert user["auth_source"] == "oidc"
assert user["role"] == "user"
def test_provision_creates_admin_user(self, user_db):
@@ -209,6 +210,7 @@ class TestProvisionOIDCUser:
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["email"] == "newemail@example.com"
assert user["display_name"] == "John D."
assert user["auth_source"] == "oidc"
def test_provision_updates_admin_role(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
@@ -256,3 +258,4 @@ class TestProvisionOIDCUser:
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["username"] != "john" # Should have a suffix
assert user["oidc_subject"] == "sub-456"
assert user["auth_source"] == "oidc"

View File

@@ -1,206 +1,107 @@
"""
Tests for OIDC integration into existing auth system.
"""Tests for auth mode and admin policy helpers used by OIDC integration."""
Tests get_auth_mode() logic with OIDC and login_required admin
restriction logic. Since main.py has heavy dependencies, we test
the logic directly rather than importing from main.
"""
from shelfmark.core.auth_modes import (
determine_auth_mode,
get_auth_check_admin_status,
is_settings_or_onboarding_path,
should_restrict_settings_to_admin,
)
class TestGetAuthModeOIDCLogic:
"""Tests that get_auth_mode logic handles OIDC correctly.
Mirrors the logic in main.py:get_auth_mode() to verify OIDC
support without importing the full app.
"""
def _get_auth_mode(self, config):
"""Replicate get_auth_mode logic with OIDC support."""
auth_mode = config.get("AUTH_METHOD", "none")
if auth_mode == "oidc":
if config.get("OIDC_DISCOVERY_URL") and config.get("OIDC_CLIENT_ID"):
return "oidc"
return "none"
if auth_mode == "builtin":
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
return "builtin"
return "none"
if auth_mode == "proxy":
if config.get("PROXY_AUTH_USER_HEADER"):
return "proxy"
return "none"
return "none"
class TestDetermineAuthMode:
def test_returns_oidc_when_fully_configured(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
}
assert self._get_auth_mode(config) == "oidc"
assert determine_auth_mode(config, cwa_db_path=None) == "oidc"
def test_returns_none_when_oidc_missing_client_id(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
}
assert self._get_auth_mode(config) == "none"
assert determine_auth_mode(config, cwa_db_path=None) == "none"
def test_returns_none_when_oidc_missing_discovery_url(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_CLIENT_ID": "shelfmark",
}
assert self._get_auth_mode(config) == "none"
def test_returns_none_when_oidc_empty_strings(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "",
"OIDC_CLIENT_ID": "",
}
assert self._get_auth_mode(config) == "none"
assert determine_auth_mode(config, cwa_db_path=None) == "none"
def test_builtin_still_works(self):
config = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hash",
}
assert self._get_auth_mode(config) == "builtin"
assert determine_auth_mode(config, cwa_db_path=None) == "builtin"
def test_builtin_requires_local_admin(self):
config = {
"AUTH_METHOD": "builtin",
}
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
def test_proxy_still_works(self):
config = {
"AUTH_METHOD": "proxy",
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
}
assert self._get_auth_mode(config) == "proxy"
assert determine_auth_mode(config, cwa_db_path=None) == "proxy"
class TestLoginRequiredOIDCLogic:
"""Tests the OIDC admin restriction logic.
Mirrors the admin check in main.py:login_required() to verify
OIDC support without importing the full app.
"""
def _check_admin_access(self, auth_mode, config, session, path):
"""Replicate login_required admin check logic with OIDC."""
if auth_mode == "none":
return True # Allowed
if "user_id" not in session:
return 401 # Unauthorized
settings_path = path.startswith("/api/settings") or path.startswith("/api/onboarding")
if auth_mode in ("proxy", "cwa", "oidc") and settings_path:
if auth_mode == "proxy":
restrict = config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
elif auth_mode == "cwa":
restrict = config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
elif auth_mode == "oidc":
restrict = config.get("OIDC_RESTRICT_SETTINGS_TO_ADMIN", False)
else:
restrict = False
if restrict and not session.get("is_admin", False):
return 403 # Forbidden
return True # Allowed
def test_oidc_unauthenticated_returns_401(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
result = self._check_admin_access("oidc", config, {}, "/api/settings/test")
assert result == 401
def test_oidc_non_admin_blocked_from_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result == 403
def test_oidc_admin_can_access_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "admin", "is_admin": True}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result is True
def test_oidc_non_admin_can_access_non_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/search")
assert result is True
def test_oidc_no_restrict_allows_non_admin_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": False}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result is True
def test_oidc_non_admin_blocked_from_onboarding(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/onboarding")
assert result == 403
class TestAuthCheckOIDCLogic:
"""Tests the /api/auth/check response logic for OIDC mode."""
def _build_auth_check_response(self, auth_mode, config, session):
"""Replicate auth check logic with OIDC."""
if auth_mode == "none":
return {"authenticated": True, "auth_required": False, "auth_mode": "none", "is_admin": True}
is_authenticated = "user_id" in session
if auth_mode == "builtin":
is_admin = True
elif auth_mode == "cwa":
restrict = config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", False) if restrict else True
elif auth_mode == "proxy":
restrict = config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", not restrict)
elif auth_mode == "oidc":
restrict = config.get("OIDC_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", False) if restrict else True
else:
is_admin = False
return {
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False,
"username": session.get("user_id") if is_authenticated else None,
def test_oidc_requires_local_admin(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
}
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
def test_oidc_authenticated_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "admin", "is_admin": True}
result = self._build_auth_check_response("oidc", config, session)
assert result["authenticated"] is True
assert result["auth_mode"] == "oidc"
assert result["is_admin"] is True
assert result["username"] == "admin"
def test_oidc_authenticated_non_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._build_auth_check_response("oidc", config, session)
assert result["is_admin"] is False
class TestSettingsRestrictionPolicy:
def test_settings_path_detection(self):
assert is_settings_or_onboarding_path("/api/settings/downloads")
assert is_settings_or_onboarding_path("/api/onboarding")
assert not is_settings_or_onboarding_path("/api/search")
def test_oidc_no_restrict_all_are_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": False}
session = {"user_id": "user", "is_admin": False}
result = self._build_auth_check_response("oidc", config, session)
assert result["is_admin"] is True
def test_default_is_admin_restricted(self):
assert should_restrict_settings_to_admin({}) is True
def test_oidc_unauthenticated(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
result = self._build_auth_check_response("oidc", config, {})
assert result["authenticated"] is False
assert result["is_admin"] is False
assert result["auth_required"] is True
def test_respects_global_users_toggle(self):
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": True}) is True
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is False
class TestAuthCheckAdminStatus:
def test_authenticated_admin_when_restricted(self):
result = get_auth_check_admin_status(
"oidc",
{"RESTRICT_SETTINGS_TO_ADMIN": True},
{"user_id": "admin", "is_admin": True},
)
assert result is True
def test_authenticated_non_admin_when_restricted(self):
result = get_auth_check_admin_status(
"oidc",
{"RESTRICT_SETTINGS_TO_ADMIN": True},
{"user_id": "user", "is_admin": False},
)
assert result is False
def test_authenticated_user_when_not_restricted(self):
result = get_auth_check_admin_status(
"proxy",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
{"user_id": "user", "is_admin": False},
)
assert result is True
def test_unauthenticated_is_never_admin(self):
result = get_auth_check_admin_status(
"builtin",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
{"is_admin": True},
)
assert result is False

View File

@@ -1,17 +1,11 @@
"""
Tests for OIDC Flask route handlers.
Tests the /api/auth/oidc/login and /api/auth/oidc/callback endpoints
using a minimal Flask test app (not the full shelfmark app).
"""
"""Tests for OIDC Flask route handlers using Authlib transport."""
import os
import tempfile
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
from unittest.mock import Mock, patch
import pytest
from flask import Flask
from flask import Flask, redirect
from shelfmark.core.user_db import UserDB
@@ -38,29 +32,18 @@ MOCK_OIDC_CONFIG = {
"OIDC_GROUP_CLAIM": "groups",
"OIDC_ADMIN_GROUP": "shelfmark-admins",
"OIDC_AUTO_PROVISION": True,
"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True,
}
MOCK_DISCOVERY = {
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"OIDC_USE_ADMIN_GROUP": True,
}
@pytest.fixture
def app(user_db, db_path):
"""Create a minimal Flask test app with OIDC routes."""
def app(user_db):
from shelfmark.core.oidc_routes import register_oidc_routes
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test-secret"
test_app.config["TESTING"] = True
register_oidc_routes(test_app, user_db)
return test_app
@@ -69,245 +52,208 @@ def client(app):
return app.test_client()
class TestOIDCClientRegistration:
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_registers_client_with_pkce_and_expected_scopes(
self, mock_register, mock_create_client, _mock_config
):
from shelfmark.core.oidc_routes import _get_oidc_client
fake_client = Mock()
mock_create_client.return_value = fake_client
client_obj, config = _get_oidc_client()
assert client_obj is fake_client
assert config["OIDC_CLIENT_ID"] == "shelfmark"
kwargs = mock_register.call_args.kwargs
assert kwargs["name"] == "shelfmark_idp"
assert kwargs["server_metadata_url"] == MOCK_OIDC_CONFIG["OIDC_DISCOVERY_URL"]
assert kwargs["overwrite"] is True
assert kwargs["client_kwargs"]["code_challenge_method"] == "S256"
scope_str = kwargs["client_kwargs"]["scope"]
assert "openid" in scope_str
assert "email" in scope_str
assert "profile" in scope_str
assert "groups" in scope_str
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes.oauth.create_client")
@patch("shelfmark.core.oidc_routes.oauth.register")
def test_does_not_append_group_claim_when_admin_group_auth_disabled(
self, mock_register, mock_create_client, mock_config
):
from shelfmark.core.oidc_routes import _get_oidc_client
config = {
**MOCK_OIDC_CONFIG,
"OIDC_SCOPES": ["openid", "email", "profile"],
"OIDC_USE_ADMIN_GROUP": False,
"OIDC_GROUP_CLAIM": "groups",
}
mock_config.return_value = config
mock_create_client.return_value = Mock()
_get_oidc_client()
scope_str = mock_register.call_args.kwargs["client_kwargs"]["scope"]
assert "groups" not in scope_str
class TestOIDCLoginEndpoint:
"""Tests for GET /api/auth/oidc/login."""
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_login_redirects_to_provider(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_redirect.return_value = redirect("https://auth.example.com/authorize")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_redirects_to_idp(self, mock_discovery, mock_config, client):
resp = client.get("/api/auth/oidc/login")
assert resp.status_code == 302
location = resp.headers["Location"]
assert location.startswith("https://auth.example.com/authorize")
assert resp.headers["Location"].startswith("https://auth.example.com/authorize")
fake_client.authorize_redirect.assert_called_once()
redirect_uri = fake_client.authorize_redirect.call_args.args[0]
assert redirect_uri.endswith("/api/auth/oidc/callback")
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_includes_required_params(self, mock_discovery, mock_config, client):
@patch("shelfmark.core.oidc_routes._get_oidc_client", side_effect=ValueError("OIDC not configured"))
def test_login_returns_500_when_not_configured(self, _mock_get_client, client):
resp = client.get("/api/auth/oidc/login")
location = resp.headers["Location"]
parsed = urlparse(location)
params = parse_qs(parsed.query)
assert params["client_id"] == ["shelfmark"]
assert params["response_type"] == ["code"]
assert "state" in params
assert "code_challenge" in params
assert params["code_challenge_method"] == ["S256"]
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_includes_scopes(self, mock_discovery, mock_config, client):
resp = client.get("/api/auth/oidc/login")
location = resp.headers["Location"]
parsed = urlparse(location)
params = parse_qs(parsed.query)
scope = params["scope"][0]
assert "openid" in scope
assert "email" in scope
assert "profile" in scope
assert "groups" in scope
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_stores_state_in_session(self, mock_discovery, mock_config, client):
with client.session_transaction() as sess:
assert "oidc_state" not in sess
client.get("/api/auth/oidc/login")
with client.session_transaction() as sess:
assert "oidc_state" in sess
assert "oidc_code_verifier" in sess
assert resp.status_code == 500
assert resp.get_json()["error"] == "OIDC not configured"
class TestOIDCCallbackEndpoint:
"""Tests for GET /api/auth/oidc/callback."""
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
def test_callback_rejects_missing_state(self, mock_config, client):
resp = client.get("/api/auth/oidc/callback?code=abc123")
assert resp.status_code == 400
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
def test_callback_rejects_mismatched_state(self, mock_config, client):
with client.session_transaction() as sess:
sess["oidc_state"] = "correct-state"
sess["oidc_code_verifier"] = "verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=wrong-state")
assert resp.status_code == 400
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_creates_session(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_creates_session(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
}
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302 # Redirect to frontend
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == "john"
assert "oidc_state" not in sess # Cleaned up
assert sess["db_user_id"] is not None
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_sets_admin_from_groups(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "admin-123",
"email": "admin@example.com",
"preferred_username": "admin",
"groups": ["users", "shelfmark-admins"],
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_sets_admin_from_groups(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "admin-123",
"email": "admin@example.com",
"preferred_username": "admin",
"groups": ["users", "shelfmark-admins"],
}
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
with client.session_transaction() as sess:
assert sess["is_admin"] is True
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_provisions_user_in_db(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "user-789",
"email": "new@example.com",
"name": "New User",
"preferred_username": "newuser",
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_falls_back_to_userinfo_endpoint(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {}
fake_client.userinfo.return_value = {
"sub": "fallback-123",
"email": "fallback@example.com",
"preferred_username": "fallback",
"groups": [],
}
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_returns_400_when_claims_missing(self, mock_get_client, client):
fake_client = Mock()
fake_client.authorize_access_token.return_value = {}
fake_client.userinfo.side_effect = RuntimeError("userinfo failed")
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
user = user_db.get_user(oidc_subject="user-789")
assert user is not None
assert user["username"] == "newuser"
assert user["email"] == "new@example.com"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 400
assert "missing user claims" in resp.get_json()["error"]
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_rejects_when_auto_provision_disabled(self, mock_exchange, mock_discovery, mock_config, client, user_db):
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_rejects_when_auto_provision_disabled(self, mock_get_client, client):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
mock_exchange.return_value = {
"sub": "unknown-user",
"email": "unknown@example.com",
"preferred_username": "unknown",
"groups": [],
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "unknown-user",
"email": "unknown@example.com",
"preferred_username": "unknown",
"groups": [],
}
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_allows_pre_created_user_by_email_when_no_provision(
self, mock_exchange, mock_discovery, mock_config, client, user_db
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_allows_pre_created_user_by_verified_email_when_no_provision(
self, mock_get_client, client, user_db
):
"""Pre-created user (by email) should log in even when auto-provision is off."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
# Admin pre-creates a user with this email (no oidc_subject yet)
user_db.create_user(username="alice", email="alice@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-alice-sub",
"email": "alice@example.com",
"preferred_username": "alice_oidc",
"groups": [],
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-alice-sub",
"email": "alice@example.com",
"email_verified": True,
"preferred_username": "alice_oidc",
"groups": [],
}
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302 # Success, redirects to frontend
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
assert sess.get("db_user_id") is not None
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_links_oidc_subject_to_pre_created_user(
self, mock_exchange, mock_discovery, mock_config, client, user_db
@patch("shelfmark.core.oidc_routes._get_oidc_client")
def test_callback_does_not_link_unverified_email_when_no_provision(
self, mock_get_client, client, user_db
):
"""When a pre-created user logs in via OIDC, their oidc_subject should be linked."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
user = user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-bob-sub",
"email": "bob@example.com",
"preferred_username": "bob_oidc",
"groups": [],
fake_client = Mock()
fake_client.authorize_access_token.return_value = {
"userinfo": {
"sub": "oidc-bob-sub",
"email": "bob@example.com",
"email_verified": False,
"preferred_username": "bob_oidc",
"groups": [],
}
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
# The OIDC subject should now be linked to the existing user
updated_user = user_db.get_user(user_id=user["id"])
assert updated_user["oidc_subject"] == "oidc-bob-sub"
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_rejects_unknown_email_when_no_provision(
self, mock_exchange, mock_discovery, mock_config, client, user_db
):
"""When auto-provision is off and no user matches by email, reject login."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
# Pre-create a user with a different email
user_db.create_user(username="charlie", email="charlie@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-unknown-sub",
"email": "stranger@example.com",
"preferred_username": "stranger",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
mock_get_client.return_value = (fake_client, config)
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
updated_user = user_db.get_user(user_id=user["id"])
assert updated_user["oidc_subject"] is None

View File

@@ -123,22 +123,30 @@ class TestQueueFilterByUser:
class TestPerUserDestination:
"""get_final_destination should respect per-user destination override in output_args."""
"""get_final_destination should resolve destination via config user context."""
def test_uses_per_user_destination(self, monkeypatch):
"""When output_args has a destination, it should be used instead of global."""
def test_passes_user_id_to_get_destination(self, monkeypatch):
"""When task has a user_id, destination resolution should receive it."""
from pathlib import Path
captured: dict[str, object] = {"user_id": None, "username": None}
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={"destination": "/user-books/alice"},
user_id=42,
username="alice",
)
def fake_get_destination(is_audiobook: bool = False, user_id=None, username=None):
captured["user_id"] = user_id
captured["username"] = username
return Path("/user-books/alice")
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
fake_get_destination,
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
@@ -149,21 +157,29 @@ class TestPerUserDestination:
result = get_final_destination(task)
assert result == Path("/user-books/alice")
assert captured["user_id"] == 42
assert captured["username"] == "alice"
def test_falls_back_to_global_without_override(self, monkeypatch):
"""When no per-user destination, should use global destination."""
def test_without_user_id_uses_global_context(self, monkeypatch):
"""When task has no user_id, destination resolution should use global context."""
from pathlib import Path
captured: dict[str, object] = {"user_id": 99, "username": "someone"}
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={},
)
def fake_get_destination(is_audiobook: bool = False, user_id=None, username=None):
captured["user_id"] = user_id
captured["username"] = username
return Path("/global/books")
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
fake_get_destination,
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
@@ -174,31 +190,74 @@ class TestPerUserDestination:
result = get_final_destination(task)
assert result == Path("/global/books")
assert captured["user_id"] is None
assert captured["username"] is None
def test_per_user_destination_empty_string_falls_back_to_global(self, monkeypatch):
"""Empty string destination should fall back to global."""
def test_content_type_routing_still_wins(self, monkeypatch):
"""Direct mode content-type routing should take priority over destination lookup."""
from pathlib import Path
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={"destination": ""},
content_type="book (fiction)",
user_id=42,
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
lambda is_audiobook=False, user_id=None, username=None: Path("/global/books"),
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
lambda ct: Path("/routed/books"),
)
from shelfmark.download.postprocess.destination import get_final_destination
result = get_final_destination(task)
assert result == Path("/global/books")
assert result == Path("/routed/books")
class TestUserDestinationTemplate:
"""Destination settings should support {User} placeholder expansion."""
def test_get_destination_expands_user_for_books(self, monkeypatch):
from pathlib import Path
from shelfmark.core.config import config
from shelfmark.core.utils import get_destination
def fake_config_get(key, default=None, user_id=None):
if key == "DESTINATION":
return "/books/{User}"
if key == "INGEST_DIR":
return "/books"
return default
monkeypatch.setattr(config, "get", fake_config_get)
result = get_destination(is_audiobook=False, user_id=42, username="alice")
assert result == Path("/books/alice")
def test_get_destination_expands_user_for_audiobooks(self, monkeypatch):
from pathlib import Path
from shelfmark.core.config import config
from shelfmark.core.utils import get_destination
def fake_config_get(key, default=None, user_id=None):
if key == "DESTINATION_AUDIOBOOK":
return "/audiobooks/{User}"
if key == "DESTINATION":
return "/books/{User}"
if key == "INGEST_DIR":
return "/books"
return default
monkeypatch.setattr(config, "get", fake_config_get)
result = get_destination(is_audiobook=True, user_id=42, username="alice")
assert result == Path("/audiobooks/alice")
class TestTaskToDictUsername:

View File

@@ -34,7 +34,7 @@ def _build_config(
"HARDLINK_TORRENTS": hardlink,
"HARDLINK_TORRENTS_AUDIOBOOK": hardlink,
}
return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
return MagicMock(side_effect=lambda key, default=None, **_kwargs: values.get(key, default))
def _sync_config(mock_config, mock_core):
@@ -475,7 +475,7 @@ def test_booklore_mode_uploads_and_cleans_staging(tmp_path):
patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), \
patch("shelfmark.download.outputs.booklore.booklore_upload_file", side_effect=_upload_stub), \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default))
result = _post_process_download(temp_file, task, Event(), status_cb)
@@ -519,7 +519,7 @@ def test_booklore_mode_rejects_unsupported_files(tmp_path):
patch("shelfmark.download.outputs.booklore.booklore_login") as mock_login, \
patch("shelfmark.download.outputs.booklore.booklore_upload_file") as mock_upload, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
mock_config.get = MagicMock(side_effect=lambda key, default=None, **_kwargs: booklore_values.get(key, default))
result = _post_process_download(temp_file, task, Event(), status_cb)

View File

@@ -69,6 +69,56 @@ class TestUserDBInitialization:
db.initialize() # Should not raise
assert os.path.exists(db_path)
def test_initialize_migrates_auth_source_column_and_backfills(self, db_path):
"""Existing DBs without auth_source should be migrated in place."""
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT,
password_hash TEXT,
oidc_subject TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
settings_json TEXT NOT NULL DEFAULT '{}'
);
"""
)
conn.execute(
"INSERT INTO users (username, password_hash, oidc_subject, role) VALUES (?, ?, ?, ?)",
("local_admin", "hash", None, "admin"),
)
conn.execute(
"INSERT INTO users (username, oidc_subject, role) VALUES (?, ?, ?)",
("oidc_user", "sub-123", "user"),
)
conn.commit()
conn.close()
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
columns = conn.execute("PRAGMA table_info(users)").fetchall()
assert "auth_source" in {str(c["name"]) for c in columns}
rows = conn.execute(
"SELECT username, auth_source FROM users ORDER BY username"
).fetchall()
by_username = {r["username"]: r["auth_source"] for r in rows}
assert by_username["local_admin"] == "builtin"
assert by_username["oidc_user"] == "oidc"
conn.close()
class TestUserCRUD:
"""Tests for user create, read, update, delete operations."""
@@ -83,6 +133,7 @@ class TestUserCRUD:
assert user["username"] == "john"
assert user["email"] == "john@example.com"
assert user["display_name"] == "John Doe"
assert user["auth_source"] == "builtin"
assert user["role"] == "user"
def test_create_user_with_password(self, user_db):
@@ -99,8 +150,14 @@ class TestUserCRUD:
username="oidcuser",
oidc_subject="sub-12345",
email="oidc@example.com",
auth_source="oidc",
)
assert user["oidc_subject"] == "sub-12345"
assert user["auth_source"] == "oidc"
def test_create_user_with_invalid_auth_source_fails(self, user_db):
with pytest.raises(ValueError, match="Invalid auth_source"):
user_db.create_user(username="john", auth_source="not-real")
def test_create_duplicate_username_fails(self, user_db):
user_db.create_user(username="john")
@@ -132,10 +189,21 @@ class TestUserCRUD:
def test_update_user(self, user_db):
user = user_db.create_user(username="john", role="user")
user_db.update_user(user["id"], role="admin", email="new@example.com")
user_db.update_user(
user["id"],
role="admin",
email="new@example.com",
auth_source="proxy",
)
updated = user_db.get_user(user_id=user["id"])
assert updated["role"] == "admin"
assert updated["email"] == "new@example.com"
assert updated["auth_source"] == "proxy"
def test_update_user_rejects_invalid_auth_source(self, user_db):
user = user_db.create_user(username="john")
with pytest.raises(ValueError, match="Invalid auth_source"):
user_db.update_user(user["id"], auth_source="bad")
def test_update_nonexistent_user_raises(self, user_db):
with pytest.raises(ValueError, match="not found"):

View File

@@ -0,0 +1,86 @@
from types import SimpleNamespace
def test_queue_book_uses_user_specific_books_output_mode(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
config_calls: list[tuple[str, object]] = []
def fake_get_book_info(_book_id, fetch_download_count=False):
assert fetch_download_count is False
return SimpleNamespace(
title="Test Book",
author="Tester",
format="epub",
size="1 MB",
preview=None,
content="book (fiction)",
)
def fake_config_get(key, default=None, user_id=None):
config_calls.append((key, user_id))
if key == "BOOKS_OUTPUT_MODE":
return "email" if user_id == 42 else "folder"
if key == "EMAIL_RECIPIENT":
return "alice@example.com" if user_id == 42 else ""
return default
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.direct_download, "get_book_info", fake_get_book_info)
monkeypatch.setattr(orchestrator.config, "get", fake_config_get)
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_book("book-1", user_id=42, username="alice")
assert success is True
assert error is None
task = captured["task"]
assert task.output_mode == "email"
assert task.output_args == {"to": "alice@example.com"}
assert ("BOOKS_OUTPUT_MODE", 42) in config_calls
def test_queue_release_uses_user_specific_books_output_mode(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
config_calls: list[tuple[str, object]] = []
def fake_config_get(key, default=None, user_id=None):
config_calls.append((key, user_id))
if key == "BOOKS_OUTPUT_MODE":
return "email" if user_id == 42 else "folder"
if key == "EMAIL_RECIPIENT":
return "alice@example.com" if user_id == 42 else ""
return default
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.config, "get", fake_config_get)
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
release_data = {
"source": "direct_download",
"source_id": "release-1",
"title": "Release Title",
"content_type": "book (fiction)",
"format": "epub",
"size": "1 MB",
}
success, error = orchestrator.queue_release(release_data, user_id=42, username="alice")
assert success is True
assert error is None
task = captured["task"]
assert task.output_mode == "email"
assert task.output_args == {"to": "alice@example.com"}
assert ("BOOKS_OUTPUT_MODE", 42) in config_calls

View File

@@ -7,6 +7,7 @@ request contexts. They do not require the full application stack.
from __future__ import annotations
import importlib
import sqlite3
from datetime import datetime, timedelta
from typing import Any, Tuple
from unittest.mock import Mock, patch
@@ -42,13 +43,18 @@ class TestGetAuthMode:
def test_get_auth_mode_builtin(self, main_module):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hashed_password",
},
return_value={"AUTH_METHOD": "builtin"},
):
assert main_module.get_auth_mode() == "builtin"
with patch.object(main_module, "has_local_password_admin", return_value=True):
assert main_module.get_auth_mode() == "builtin"
def test_get_auth_mode_builtin_without_local_admin_falls_back_to_none(self, main_module):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"AUTH_METHOD": "builtin"},
):
with patch.object(main_module, "has_local_password_admin", return_value=False):
assert main_module.get_auth_mode() == "none"
def test_get_auth_mode_proxy(self, main_module):
with patch(
@@ -102,6 +108,7 @@ class TestAuthCheckEndpoint:
with patch("shelfmark.core.settings_registry.load_config_file", return_value={}):
with main_module.app.test_request_context("/api/auth/check"):
main_module.session["user_id"] = "admin"
main_module.session["is_admin"] = True
resp = _as_response(main_module.api_auth_check())
data = resp.get_json()
@@ -118,7 +125,6 @@ class TestAuthCheckEndpoint:
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True,
"PROXY_AUTH_LOGOUT_URL": "https://auth.example.com/logout",
},
):
@@ -166,15 +172,16 @@ class TestLoginEndpoint:
assert data.get("success") is True
def test_login_builtin_success(self, main_module):
mock_user_db = Mock()
mock_user_db.get_user.return_value = {
"id": 1,
"username": "admin",
"password_hash": "hash",
"role": "admin",
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "is_account_locked", return_value=False):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hash",
},
):
with patch.object(main_module, "user_db", mock_user_db):
with patch.object(main_module, "check_password_hash", return_value=True):
with main_module.app.test_request_context(
"/api/auth/login",
@@ -188,6 +195,94 @@ class TestLoginEndpoint:
assert resp.status_code == 200
assert data.get("success") is True
def test_login_cwa_provisions_db_user(self, main_module, tmp_path):
cwa_db_path = tmp_path / "app.db"
username = "cwa_test_user"
conn = sqlite3.connect(cwa_db_path)
conn.execute(
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
)
conn.execute(
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
(username, "hashed_password", 1, "cwa@example.com"),
)
conn.commit()
conn.close()
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
with patch.object(main_module, "is_account_locked", return_value=False):
with patch.object(main_module, "CWA_DB_PATH", cwa_db_path):
with patch.object(main_module, "check_password_hash", return_value=True):
with main_module.app.test_request_context(
"/api/auth/login",
method="POST",
json={"username": username, "password": "correct", "remember_me": False},
):
resp = _as_response(main_module.api_login())
data = resp.get_json()
assert main_module.session.get("user_id") == username
assert main_module.session.get("is_admin") is True
assert main_module.session.get("db_user_id") is not None
assert resp.status_code == 200
assert data.get("success") is True
db_user = main_module.user_db.get_user(username=username)
assert db_user["email"] == "cwa@example.com"
assert db_user["role"] == "admin"
assert db_user["auth_source"] == "cwa"
def test_login_cwa_avoids_overwriting_local_username_collision(self, main_module, tmp_path):
cwa_db_path = tmp_path / "app.db"
username = "collision_admin"
external_email = "collision.cwa@example.com"
local_user = main_module.user_db.create_user(
username=username,
email="collision.local@example.com",
role="admin",
auth_source="builtin",
)
conn = sqlite3.connect(cwa_db_path)
conn.execute(
"CREATE TABLE user (name TEXT PRIMARY KEY, password TEXT, role INTEGER, email TEXT)"
)
conn.execute(
"INSERT INTO user (name, password, role, email) VALUES (?, ?, ?, ?)",
(username, "hashed_password", 1, external_email),
)
conn.commit()
conn.close()
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
with patch.object(main_module, "is_account_locked", return_value=False):
with patch.object(main_module, "CWA_DB_PATH", cwa_db_path):
with patch.object(main_module, "check_password_hash", return_value=True):
with main_module.app.test_request_context(
"/api/auth/login",
method="POST",
json={"username": username, "password": "correct", "remember_me": False},
):
resp = _as_response(main_module.api_login())
data = resp.get_json()
assert resp.status_code == 200
assert data.get("success") is True
assert main_module.session.get("user_id") == username
assert main_module.session.get("db_user_id") is not None
local_after = main_module.user_db.get_user(user_id=local_user["id"])
assert local_after is not None
assert local_after["auth_source"] == "builtin"
assert local_after["email"] == "collision.local@example.com"
provisioned_cwa_user = next(
user for user in main_module.user_db.list_users()
if user.get("auth_source") == "cwa" and user.get("email") == external_email
)
assert provisioned_cwa_user["username"].startswith(f"{username}__cwa")
class TestLogoutEndpoint:
def test_logout_proxy_returns_logout_url(self, main_module):

View File

@@ -57,7 +57,6 @@ class TestProxyAuthMiddleware:
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": False,
},
):
with main_module.app.test_request_context(
@@ -68,8 +67,62 @@ class TestProxyAuthMiddleware:
assert result is None
assert main_module.session.get("user_id") == "proxyuser"
assert main_module.session.get("is_admin") is True
db_user_id = main_module.session.get("db_user_id")
assert db_user_id is not None
db_user = main_module.user_db.get_user(user_id=db_user_id)
assert db_user is not None
assert db_user["username"] == "proxyuser"
assert db_user["auth_source"] == "proxy"
assert main_module.session.permanent is False
def test_proxy_takes_over_existing_local_username(self, main_module):
existing = main_module.user_db.create_user(
username="proxy_takeover_local",
role="user",
auth_source="builtin",
)
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
):
with main_module.app.test_request_context(
"/api/search",
headers={"X-Auth-User": "proxy_takeover_local"},
):
result = main_module.proxy_auth_middleware()
assert result is None
db_user_id = main_module.session.get("db_user_id")
db_user = main_module.user_db.get_user(user_id=db_user_id)
assert db_user is not None
assert db_user["id"] == existing["id"]
assert db_user["username"] == "proxy_takeover_local"
assert db_user["auth_source"] == "proxy"
def test_reprovisions_when_proxy_identity_changes(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
},
):
with main_module.app.test_request_context(
"/api/search",
headers={"X-Auth-User": "proxyuser2"},
):
main_module.session["user_id"] = "old-user"
main_module.session["db_user_id"] = 999999
result = main_module.proxy_auth_middleware()
assert result is None
assert main_module.session.get("user_id") == "proxyuser2"
db_user_id = main_module.session.get("db_user_id")
db_user = main_module.user_db.get_user(user_id=db_user_id)
assert db_user["username"] == "proxyuser2"
def test_returns_401_when_header_missing_on_protected_path(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
@@ -89,7 +142,6 @@ class TestProxyAuthMiddleware:
"shelfmark.core.settings_registry.load_config_file",
return_value={
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True,
"PROXY_AUTH_ADMIN_GROUP_HEADER": "X-Auth-Groups",
"PROXY_AUTH_ADMIN_GROUP_NAME": "admins",
},
@@ -139,14 +191,15 @@ class TestLoginRequiredDecorator:
assert resp[0]["success"] is True
def test_builtin_mode_does_not_apply_cwa_admin_setting(self, main_module, view):
def test_settings_access_not_restricted_when_global_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"CWA_RESTRICT_SETTINGS_TO_ADMIN": True},
return_value={"RESTRICT_SETTINGS_TO_ADMIN": False},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "admin"
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = decorated()
@@ -156,7 +209,7 @@ class TestLoginRequiredDecorator:
with patch.object(main_module, "get_auth_mode", return_value="proxy"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN": True},
return_value={"RESTRICT_SETTINGS_TO_ADMIN": True},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"
@@ -172,7 +225,7 @@ class TestLoginRequiredDecorator:
with patch.object(main_module, "get_auth_mode", return_value="cwa"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
return_value={"CWA_RESTRICT_SETTINGS_TO_ADMIN": True},
return_value={"RESTRICT_SETTINGS_TO_ADMIN": True},
):
with main_module.app.test_request_context("/api/settings/general"):
main_module.session["user_id"] = "user"