mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-05-19 11:34:53 -04:00
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:
@@ -14,3 +14,4 @@ emoji
|
||||
rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
authlib>=1.6.6,<1.7
|
||||
|
||||
123
shelfmark/config/migrations.py
Normal file
123
shelfmark/config/migrations.py
Normal 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}")
|
||||
@@ -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)
|
||||
|
||||
87
shelfmark/config/security_handlers.py
Normal file
87
shelfmark/config/security_handlers.py
Normal 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)}"}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
196
shelfmark/core/admin_settings_routes.py
Normal file
196
shelfmark/core/admin_settings_routes.py
Normal 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)
|
||||
104
shelfmark/core/auth_modes.py
Normal file
104
shelfmark/core/auth_modes.py
Normal 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))
|
||||
@@ -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:
|
||||
|
||||
75
shelfmark/core/cwa_user_sync.py
Normal file
75
shelfmark/core/cwa_user_sync.py
Normal 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,
|
||||
}
|
||||
283
shelfmark/core/external_user_linking.py
Normal file
283
shelfmark/core/external_user_linking.py
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export { EnvLockBadge } from './EnvLockBadge';
|
||||
export { FieldWrapper } from './FieldWrapper';
|
||||
export { SettingsSubpage } from './SettingsSubpage';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
276
src/frontend/src/components/settings/users/UserCard.tsx
Normal file
276
src/frontend/src/components/settings/users/UserCard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
284
src/frontend/src/components/settings/users/UserListView.tsx
Normal file
284
src/frontend/src/components/settings/users/UserListView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
10
src/frontend/src/components/settings/users/index.ts
Normal file
10
src/frontend/src/components/settings/users/index.ts
Normal 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';
|
||||
72
src/frontend/src/components/settings/users/types.ts
Normal file
72
src/frontend/src/components/settings/users/types.ts
Normal 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.';
|
||||
};
|
||||
69
src/frontend/src/components/settings/users/useUserForm.ts
Normal file
69
src/frontend/src/components/settings/users/useUserForm.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
151
src/frontend/src/components/settings/users/useUserMutations.ts
Normal file
151
src/frontend/src/components/settings/users/useUserMutations.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
84
src/frontend/src/components/settings/users/useUsersFetch.ts
Normal file
84
src/frontend/src/components/settings/users/useUsersFetch.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -87,6 +87,8 @@ export function Tooltip({
|
||||
ref={triggerRef}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
onFocusCapture={showTooltip}
|
||||
onBlurCapture={hideTooltip}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
62
tests/core/test_config_user_overrides.py
Normal file
62
tests/core/test_config_user_overrides.py
Normal 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"
|
||||
95
tests/core/test_cwa_user_sync.py
Normal file
95
tests/core/test_cwa_user_sync.py
Normal 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"
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
86
tests/download/test_orchestrator_user_output_mode.py
Normal file
86
tests/download/test_orchestrator_user_output_mode.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user