Build 2602261717: master invite policy and self-service invite controls
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602261636
|
2602261717
|
||||||
@@ -67,6 +67,7 @@ def _load_current_user_from_token(token: str, request: Optional[Request] = None)
|
|||||||
"auth_provider": user.get("auth_provider", "local"),
|
"auth_provider": user.get("auth_provider", "local"),
|
||||||
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
|
||||||
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
|
||||||
|
"invite_management_enabled": bool(user.get("invite_management_enabled", False)),
|
||||||
"profile_id": user.get("profile_id"),
|
"profile_id": user.get("profile_id"),
|
||||||
"expires_at": user.get("expires_at"),
|
"expires_at": user.get("expires_at"),
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
"is_expired": bool(user.get("is_expired", False)),
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
BUILD_NUMBER = "2602261636"
|
BUILD_NUMBER = "2602261717"
|
||||||
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ def init_db() -> None:
|
|||||||
last_login_at TEXT,
|
last_login_at TEXT,
|
||||||
is_blocked INTEGER NOT NULL DEFAULT 0,
|
is_blocked INTEGER NOT NULL DEFAULT 0,
|
||||||
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
|
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
invite_management_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
profile_id INTEGER,
|
profile_id INTEGER,
|
||||||
expires_at TEXT,
|
expires_at TEXT,
|
||||||
invited_by_code TEXT,
|
invited_by_code TEXT,
|
||||||
@@ -341,6 +342,10 @@ def init_db() -> None:
|
|||||||
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
|
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
@@ -498,6 +503,7 @@ def create_user(
|
|||||||
auth_provider: str = "local",
|
auth_provider: str = "local",
|
||||||
jellyseerr_user_id: Optional[int] = None,
|
jellyseerr_user_id: Optional[int] = None,
|
||||||
auto_search_enabled: bool = True,
|
auto_search_enabled: bool = True,
|
||||||
|
invite_management_enabled: bool = False,
|
||||||
profile_id: Optional[int] = None,
|
profile_id: Optional[int] = None,
|
||||||
expires_at: Optional[str] = None,
|
expires_at: Optional[str] = None,
|
||||||
invited_by_code: Optional[str] = None,
|
invited_by_code: Optional[str] = None,
|
||||||
@@ -515,6 +521,7 @@ def create_user(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
auto_search_enabled,
|
auto_search_enabled,
|
||||||
|
invite_management_enabled,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -530,6 +537,7 @@ def create_user(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
1 if auto_search_enabled else 0,
|
1 if auto_search_enabled else 0,
|
||||||
|
1 if invite_management_enabled else 0,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -545,6 +553,7 @@ def create_user_if_missing(
|
|||||||
auth_provider: str = "local",
|
auth_provider: str = "local",
|
||||||
jellyseerr_user_id: Optional[int] = None,
|
jellyseerr_user_id: Optional[int] = None,
|
||||||
auto_search_enabled: bool = True,
|
auto_search_enabled: bool = True,
|
||||||
|
invite_management_enabled: bool = False,
|
||||||
profile_id: Optional[int] = None,
|
profile_id: Optional[int] = None,
|
||||||
expires_at: Optional[str] = None,
|
expires_at: Optional[str] = None,
|
||||||
invited_by_code: Optional[str] = None,
|
invited_by_code: Optional[str] = None,
|
||||||
@@ -562,6 +571,7 @@ def create_user_if_missing(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
auto_search_enabled,
|
auto_search_enabled,
|
||||||
|
invite_management_enabled,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -577,6 +587,7 @@ def create_user_if_missing(
|
|||||||
jellyseerr_user_id,
|
jellyseerr_user_id,
|
||||||
created_at,
|
created_at,
|
||||||
1 if auto_search_enabled else 0,
|
1 if auto_search_enabled else 0,
|
||||||
|
1 if invite_management_enabled else 0,
|
||||||
profile_id,
|
profile_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
invited_by_code,
|
invited_by_code,
|
||||||
@@ -592,7 +603,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
profile_id, expires_at, invited_by_code, invited_at,
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
jellyfin_password_hash, last_jellyfin_auth_at
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = ? COLLATE NOCASE
|
WHERE username = ? COLLATE NOCASE
|
||||||
@@ -612,13 +623,14 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
|
|||||||
"last_login_at": row[7],
|
"last_login_at": row[7],
|
||||||
"is_blocked": bool(row[8]),
|
"is_blocked": bool(row[8]),
|
||||||
"auto_search_enabled": bool(row[9]),
|
"auto_search_enabled": bool(row[9]),
|
||||||
"profile_id": row[10],
|
"invite_management_enabled": bool(row[10]),
|
||||||
"expires_at": row[11],
|
"profile_id": row[11],
|
||||||
"invited_by_code": row[12],
|
"expires_at": row[12],
|
||||||
"invited_at": row[13],
|
"invited_by_code": row[13],
|
||||||
"is_expired": _is_datetime_in_past(row[11]),
|
"invited_at": row[14],
|
||||||
"jellyfin_password_hash": row[14],
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
"last_jellyfin_auth_at": row[15],
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -628,7 +640,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
|
||||||
created_at, last_login_at, is_blocked, auto_search_enabled,
|
created_at, last_login_at, is_blocked, auto_search_enabled,
|
||||||
profile_id, expires_at, invited_by_code, invited_at,
|
invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at,
|
||||||
jellyfin_password_hash, last_jellyfin_auth_at
|
jellyfin_password_hash, last_jellyfin_auth_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -648,13 +660,14 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
|
|||||||
"last_login_at": row[7],
|
"last_login_at": row[7],
|
||||||
"is_blocked": bool(row[8]),
|
"is_blocked": bool(row[8]),
|
||||||
"auto_search_enabled": bool(row[9]),
|
"auto_search_enabled": bool(row[9]),
|
||||||
"profile_id": row[10],
|
"invite_management_enabled": bool(row[10]),
|
||||||
"expires_at": row[11],
|
"profile_id": row[11],
|
||||||
"invited_by_code": row[12],
|
"expires_at": row[12],
|
||||||
"invited_at": row[13],
|
"invited_by_code": row[13],
|
||||||
"is_expired": _is_datetime_in_past(row[11]),
|
"invited_at": row[14],
|
||||||
"jellyfin_password_hash": row[14],
|
"is_expired": _is_datetime_in_past(row[12]),
|
||||||
"last_jellyfin_auth_at": row[15],
|
"jellyfin_password_hash": row[15],
|
||||||
|
"last_jellyfin_auth_at": row[16],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_all_users() -> list[Dict[str, Any]]:
|
def get_all_users() -> list[Dict[str, Any]]:
|
||||||
@@ -662,8 +675,8 @@ def get_all_users() -> list[Dict[str, Any]]:
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
|
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
|
||||||
last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at,
|
last_login_at, is_blocked, auto_search_enabled, invite_management_enabled,
|
||||||
invited_by_code, invited_at
|
profile_id, expires_at, invited_by_code, invited_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY username COLLATE NOCASE
|
ORDER BY username COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
@@ -681,11 +694,12 @@ def get_all_users() -> list[Dict[str, Any]]:
|
|||||||
"last_login_at": row[6],
|
"last_login_at": row[6],
|
||||||
"is_blocked": bool(row[7]),
|
"is_blocked": bool(row[7]),
|
||||||
"auto_search_enabled": bool(row[8]),
|
"auto_search_enabled": bool(row[8]),
|
||||||
"profile_id": row[9],
|
"invite_management_enabled": bool(row[9]),
|
||||||
"expires_at": row[10],
|
"profile_id": row[10],
|
||||||
"invited_by_code": row[11],
|
"expires_at": row[11],
|
||||||
"invited_at": row[12],
|
"invited_by_code": row[12],
|
||||||
"is_expired": _is_datetime_in_past(row[10]),
|
"invited_at": row[13],
|
||||||
|
"is_expired": _is_datetime_in_past(row[11]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@@ -788,6 +802,16 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_invite_management_enabled(username: str, enabled: bool) -> None:
|
||||||
|
with _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET invite_management_enabled = ? WHERE username = ? COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0, username),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -799,6 +823,17 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
|
|||||||
return cursor.rowcount
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int:
|
||||||
|
with _connect() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users SET invite_management_enabled = ? WHERE role != 'admin'
|
||||||
|
""",
|
||||||
|
(1 if enabled else 0,),
|
||||||
|
)
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
|
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..db import (
|
|||||||
get_all_users,
|
get_all_users,
|
||||||
get_cached_requests,
|
get_cached_requests,
|
||||||
get_cached_requests_count,
|
get_cached_requests_count,
|
||||||
|
get_setting,
|
||||||
get_request_cache_overview,
|
get_request_cache_overview,
|
||||||
get_request_cache_missing_titles,
|
get_request_cache_missing_titles,
|
||||||
get_request_cache_stats,
|
get_request_cache_stats,
|
||||||
@@ -34,9 +35,12 @@ from ..db import (
|
|||||||
delete_user_activity_by_username,
|
delete_user_activity_by_username,
|
||||||
set_user_auto_search_enabled,
|
set_user_auto_search_enabled,
|
||||||
set_auto_search_enabled_for_non_admin_users,
|
set_auto_search_enabled_for_non_admin_users,
|
||||||
|
set_user_invite_management_enabled,
|
||||||
|
set_invite_management_enabled_for_non_admin_users,
|
||||||
set_user_profile_id,
|
set_user_profile_id,
|
||||||
set_user_expires_at,
|
set_user_expires_at,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
|
set_jellyfin_auth_cache,
|
||||||
set_user_role,
|
set_user_role,
|
||||||
run_integrity_check,
|
run_integrity_check,
|
||||||
vacuum_db,
|
vacuum_db,
|
||||||
@@ -83,6 +87,7 @@ from ..routers.branding import save_branding_image
|
|||||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"jellyseerr_api_key",
|
"jellyseerr_api_key",
|
||||||
@@ -1107,6 +1112,24 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
|
|||||||
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{username}/invite-access")
|
||||||
|
async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||||
|
user = get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
set_user_invite_management_enabled(username, enabled)
|
||||||
|
refreshed = get_user_by_username(username)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"username": username,
|
||||||
|
"invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled,
|
||||||
|
"user": refreshed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/{username}/profile")
|
@router.post("/users/{username}/profile")
|
||||||
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
user = get_user_by_username(username)
|
user = get_user_by_username(username)
|
||||||
@@ -1172,6 +1195,20 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/invite-access/bulk")
|
||||||
|
async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
||||||
|
updated = set_invite_management_enabled_for_non_admin_users(enabled)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"enabled": enabled,
|
||||||
|
"updated": updated,
|
||||||
|
"scope": "non-admin-users",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/users/profile/bulk")
|
@router.post("/users/profile/bulk")
|
||||||
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -1242,12 +1279,30 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
|||||||
user = get_user_by_username(username)
|
user = get_user_by_username(username)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if user.get("auth_provider") != "local":
|
new_password_clean = new_password.strip()
|
||||||
|
auth_provider = str(user.get("auth_provider") or "local").lower()
|
||||||
|
if auth_provider == "local":
|
||||||
|
set_user_password(username, new_password_clean)
|
||||||
|
return {"status": "ok", "username": username, "provider": "local"}
|
||||||
|
if auth_provider == "jellyfin":
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.")
|
||||||
|
try:
|
||||||
|
jf_user = await client.find_user_by_name(username)
|
||||||
|
user_id = client._extract_user_id(jf_user)
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Jellyfin user ID not found")
|
||||||
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
|
||||||
|
set_jellyfin_auth_cache(username, new_password_clean)
|
||||||
|
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Password changes are only available for local users."
|
status_code=400,
|
||||||
|
detail="Password changes are not available for this sign-in provider.",
|
||||||
)
|
)
|
||||||
set_user_password(username, new_password.strip())
|
|
||||||
return {"status": "ok", "username": username}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profiles")
|
@router.get("/profiles")
|
||||||
@@ -1384,6 +1439,63 @@ async def get_invites() -> Dict[str, Any]:
|
|||||||
return {"invites": results}
|
return {"invites": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/invites/policy")
|
||||||
|
async def get_invite_policy() -> Dict[str, Any]:
|
||||||
|
users = get_all_users()
|
||||||
|
non_admin_users = [user for user in users if user.get("role") != "admin"]
|
||||||
|
invite_access_enabled_count = sum(
|
||||||
|
1 for user in non_admin_users if bool(user.get("invite_management_enabled", False))
|
||||||
|
)
|
||||||
|
raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||||
|
master_invite_id: Optional[int] = None
|
||||||
|
master_invite: Optional[Dict[str, Any]] = None
|
||||||
|
if raw_master_invite_id not in (None, ""):
|
||||||
|
try:
|
||||||
|
candidate = int(str(raw_master_invite_id).strip())
|
||||||
|
if candidate > 0:
|
||||||
|
master_invite_id = candidate
|
||||||
|
master_invite = get_signup_invite_by_id(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
master_invite_id = None
|
||||||
|
master_invite = None
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"policy": {
|
||||||
|
"master_invite_id": master_invite_id if master_invite is not None else None,
|
||||||
|
"master_invite": master_invite,
|
||||||
|
"non_admin_users": len(non_admin_users),
|
||||||
|
"invite_access_enabled_users": invite_access_enabled_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invites/policy")
|
||||||
|
async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
||||||
|
master_invite_value = payload.get("master_invite_id")
|
||||||
|
if master_invite_value in (None, "", 0, "0"):
|
||||||
|
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
|
||||||
|
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
|
||||||
|
try:
|
||||||
|
master_invite_id = int(master_invite_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc
|
||||||
|
if master_invite_id <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="master_invite_id must be a positive number")
|
||||||
|
invite = get_signup_invite_by_id(master_invite_id)
|
||||||
|
if not invite:
|
||||||
|
raise HTTPException(status_code=404, detail="Master invite not found")
|
||||||
|
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"policy": {
|
||||||
|
"master_invite_id": master_invite_id,
|
||||||
|
"master_invite": invite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invites/trace")
|
@router.get("/invites/trace")
|
||||||
async def get_invite_trace() -> Dict[str, Any]:
|
async def get_invite_trace() -> Dict[str, Any]:
|
||||||
return {"status": "ok", "trace": _build_invite_trace_payload()}
|
return {"status": "ok", "trace": _build_invite_trace_payload()}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from ..db import (
|
|||||||
get_user_request_stats,
|
get_user_request_stats,
|
||||||
get_global_request_leader,
|
get_global_request_leader,
|
||||||
get_global_request_total,
|
get_global_request_total,
|
||||||
|
get_setting,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
@@ -42,6 +43,7 @@ from ..services.user_cache import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_username(value: str) -> str:
|
def _normalize_username(value: str) -> str:
|
||||||
@@ -275,6 +277,89 @@ def _get_owned_invite(invite_id: int, current_user: dict) -> dict:
|
|||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
|
||||||
|
def _self_service_invite_access_enabled(current_user: dict) -> bool:
|
||||||
|
if str(current_user.get("role") or "").lower() == "admin":
|
||||||
|
return True
|
||||||
|
return bool(current_user.get("invite_management_enabled", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_self_service_invite_access(current_user: dict) -> None:
|
||||||
|
if _self_service_invite_access_enabled(current_user):
|
||||||
|
return
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invite management is not enabled for your account.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_self_service_master_invite() -> dict | None:
|
||||||
|
raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY)
|
||||||
|
if raw_value is None:
|
||||||
|
return None
|
||||||
|
candidate = str(raw_value).strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
invite_id = int(candidate)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if invite_id <= 0:
|
||||||
|
return None
|
||||||
|
return get_signup_invite_by_id(invite_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_self_service_master_invite(invite: dict | None) -> dict | None:
|
||||||
|
if not isinstance(invite, dict):
|
||||||
|
return None
|
||||||
|
profile = None
|
||||||
|
profile_id = invite.get("profile_id")
|
||||||
|
if isinstance(profile_id, int):
|
||||||
|
profile = get_user_profile(profile_id)
|
||||||
|
return {
|
||||||
|
"id": invite.get("id"),
|
||||||
|
"code": invite.get("code"),
|
||||||
|
"label": invite.get("label"),
|
||||||
|
"description": invite.get("description"),
|
||||||
|
"profile_id": invite.get("profile_id"),
|
||||||
|
"profile": (
|
||||||
|
{"id": profile.get("id"), "name": profile.get("name")}
|
||||||
|
if isinstance(profile, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"role": invite.get("role"),
|
||||||
|
"max_uses": invite.get("max_uses"),
|
||||||
|
"enabled": bool(invite.get("enabled")),
|
||||||
|
"expires_at": invite.get("expires_at"),
|
||||||
|
"is_expired": bool(invite.get("is_expired")),
|
||||||
|
"is_usable": bool(invite.get("is_usable")),
|
||||||
|
"created_at": invite.get("created_at"),
|
||||||
|
"updated_at": invite.get("updated_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]:
|
||||||
|
profile_id_raw = master_invite.get("profile_id")
|
||||||
|
profile_id: int | None = None
|
||||||
|
if isinstance(profile_id_raw, int):
|
||||||
|
profile_id = profile_id_raw
|
||||||
|
elif profile_id_raw not in (None, ""):
|
||||||
|
try:
|
||||||
|
profile_id = int(profile_id_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
profile_id = None
|
||||||
|
role_value = str(master_invite.get("role") or "").strip().lower()
|
||||||
|
role = role_value if role_value in {"user", "admin"} else "user"
|
||||||
|
max_uses_raw = master_invite.get("max_uses")
|
||||||
|
try:
|
||||||
|
max_uses = int(max_uses_raw) if max_uses_raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_uses = None
|
||||||
|
enabled = bool(master_invite.get("enabled", True))
|
||||||
|
expires_at_value = master_invite.get("expires_at")
|
||||||
|
expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None
|
||||||
|
return profile_id, role, max_uses, enabled, expires_at
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||||
user = verify_user_password(form_data.username, form_data.password)
|
user = verify_user_password(form_data.username, form_data.password)
|
||||||
@@ -568,14 +653,25 @@ async def profile_invites(current_user: dict = Depends(get_current_user)) -> dic
|
|||||||
username = str(current_user.get("username") or "").strip()
|
username = str(current_user.get("username") or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
invite_access_enabled = _self_service_invite_access_enabled(current_user)
|
||||||
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
|
invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)]
|
||||||
return {"invites": invites, "count": len(invites)}
|
return {
|
||||||
|
"invites": invites,
|
||||||
|
"count": len(invites),
|
||||||
|
"invite_access": {
|
||||||
|
"enabled": invite_access_enabled,
|
||||||
|
"managed_by_master": bool(master_invite),
|
||||||
|
},
|
||||||
|
"master_invite": _serialize_self_service_master_invite(master_invite),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/profile/invites")
|
@router.post("/profile/invites")
|
||||||
async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
username = str(current_user.get("username") or "").strip()
|
username = str(current_user.get("username") or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
@@ -603,20 +699,32 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_
|
|||||||
if description is not None:
|
if description is not None:
|
||||||
description = str(description).strip() or None
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
if master_invite:
|
||||||
|
if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).",
|
||||||
|
)
|
||||||
|
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||||
|
if profile_id is not None and not get_user_profile(profile_id):
|
||||||
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
enabled = bool(payload.get("enabled", True))
|
enabled = bool(payload.get("enabled", True))
|
||||||
|
|
||||||
profile_id = current_user.get("profile_id")
|
profile_id = current_user.get("profile_id")
|
||||||
if not isinstance(profile_id, int) or profile_id <= 0:
|
if not isinstance(profile_id, int) or profile_id <= 0:
|
||||||
profile_id = None
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
|
||||||
invite = create_signup_invite(
|
invite = create_signup_invite(
|
||||||
code=code,
|
code=code,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
role="user",
|
role=role,
|
||||||
max_uses=max_uses,
|
max_uses=max_uses,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
@@ -631,6 +739,7 @@ async def update_profile_invite(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
existing = _get_owned_invite(invite_id, current_user)
|
existing = _get_owned_invite(invite_id, current_user)
|
||||||
|
|
||||||
requested_code = payload.get("code", existing.get("code"))
|
requested_code = payload.get("code", existing.get("code"))
|
||||||
@@ -651,18 +760,27 @@ async def update_profile_invite(
|
|||||||
if description is not None:
|
if description is not None:
|
||||||
description = str(description).strip() or None
|
description = str(description).strip() or None
|
||||||
|
|
||||||
|
master_invite = _get_self_service_master_invite()
|
||||||
|
if master_invite:
|
||||||
|
profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite)
|
||||||
|
if profile_id is not None and not get_user_profile(profile_id):
|
||||||
|
profile_id = None
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at")))
|
||||||
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
enabled_raw = payload.get("enabled", existing.get("enabled"))
|
||||||
enabled = bool(enabled_raw)
|
enabled = bool(enabled_raw)
|
||||||
|
profile_id = existing.get("profile_id")
|
||||||
|
role = existing.get("role")
|
||||||
|
|
||||||
invite = update_signup_invite(
|
invite = update_signup_invite(
|
||||||
invite_id,
|
invite_id,
|
||||||
code=code,
|
code=code,
|
||||||
label=label,
|
label=label,
|
||||||
description=description,
|
description=description,
|
||||||
profile_id=existing.get("profile_id"),
|
profile_id=profile_id,
|
||||||
role=existing.get("role"),
|
role=role,
|
||||||
max_uses=max_uses,
|
max_uses=max_uses,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
expires_at=expires_at,
|
expires_at=expires_at,
|
||||||
@@ -674,6 +792,7 @@ async def update_profile_invite(
|
|||||||
|
|
||||||
@router.delete("/profile/invites/{invite_id}")
|
@router.delete("/profile/invites/{invite_id}")
|
||||||
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
_require_self_service_invite_access(current_user)
|
||||||
_get_owned_invite(invite_id, current_user)
|
_get_owned_invite(invite_id, current_user)
|
||||||
deleted = delete_signup_invite(invite_id)
|
deleted = delete_signup_invite(invite_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
@@ -683,11 +802,6 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
|
|||||||
|
|
||||||
@router.post("/password")
|
@router.post("/password")
|
||||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||||
if current_user.get("auth_provider") != "local":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Password changes are only available for local users.",
|
|
||||||
)
|
|
||||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||||
@@ -696,8 +810,64 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||||
)
|
)
|
||||||
user = verify_user_password(current_user["username"], current_password)
|
username = str(current_user.get("username") or "").strip()
|
||||||
|
auth_provider = str(current_user.get("auth_provider") or "local").lower()
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
|
new_password_clean = new_password.strip()
|
||||||
|
|
||||||
|
if auth_provider == "local":
|
||||||
|
user = verify_user_password(username, current_password)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||||
set_user_password(current_user["username"], new_password.strip())
|
set_user_password(username, new_password_clean)
|
||||||
return {"status": "ok"}
|
return {"status": "ok", "provider": "local"}
|
||||||
|
|
||||||
|
if auth_provider == "jellyfin":
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||||
|
if not client.configured():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Jellyfin is not configured for password passthrough.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
auth_result = await client.authenticate_by_name(username, current_password)
|
||||||
|
if not isinstance(auth_result, dict) or not auth_result.get("User"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
detail = _extract_http_error_detail(exc)
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
||||||
|
) from exc
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Jellyfin password validation failed: {detail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
jf_user = await client.find_user_by_name(username)
|
||||||
|
user_id = client._extract_user_id(jf_user)
|
||||||
|
if not user_id:
|
||||||
|
raise RuntimeError("Jellyfin user ID not found")
|
||||||
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = _extract_http_error_detail(exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Jellyfin password update failed: {detail}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
|
||||||
|
set_jellyfin_auth_cache(username, new_password_clean)
|
||||||
|
return {"status": "ok", "provider": "jellyfin"}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Password changes are not available for this sign-in provider.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type AdminUserLite = {
|
|||||||
username: string
|
username: string
|
||||||
role: string
|
role: string
|
||||||
auth_provider?: string | null
|
auth_provider?: string | null
|
||||||
|
invite_management_enabled?: boolean
|
||||||
profile_id?: number | null
|
profile_id?: number | null
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
@@ -70,6 +71,13 @@ type ProfileForm = {
|
|||||||
|
|
||||||
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
|
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
|
||||||
|
|
||||||
|
type InvitePolicy = {
|
||||||
|
master_invite_id?: number | null
|
||||||
|
master_invite?: Invite | null
|
||||||
|
non_admin_users?: number
|
||||||
|
invite_access_enabled_users?: number
|
||||||
|
}
|
||||||
|
|
||||||
const defaultInviteForm = (): InviteForm => ({
|
const defaultInviteForm = (): InviteForm => ({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
@@ -109,6 +117,8 @@ export default function AdminInviteManagementPage() {
|
|||||||
const [profileSaving, setProfileSaving] = useState(false)
|
const [profileSaving, setProfileSaving] = useState(false)
|
||||||
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
|
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
|
||||||
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
|
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
|
||||||
|
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
|
||||||
|
const [invitePolicySaving, setInvitePolicySaving] = useState(false)
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
@@ -121,6 +131,8 @@ export default function AdminInviteManagementPage() {
|
|||||||
|
|
||||||
const [bulkProfileId, setBulkProfileId] = useState('')
|
const [bulkProfileId, setBulkProfileId] = useState('')
|
||||||
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
|
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
|
||||||
|
const [masterInviteSelection, setMasterInviteSelection] = useState('')
|
||||||
|
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
|
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
|
||||||
const [traceFilter, setTraceFilter] = useState('')
|
const [traceFilter, setTraceFilter] = useState('')
|
||||||
|
|
||||||
@@ -151,10 +163,11 @@ export default function AdminInviteManagementPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const [inviteRes, profileRes, usersRes] = await Promise.all([
|
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([
|
||||||
authFetch(`${baseUrl}/admin/invites`),
|
authFetch(`${baseUrl}/admin/invites`),
|
||||||
authFetch(`${baseUrl}/admin/profiles`),
|
authFetch(`${baseUrl}/admin/profiles`),
|
||||||
authFetch(`${baseUrl}/admin/users`),
|
authFetch(`${baseUrl}/admin/users`),
|
||||||
|
authFetch(`${baseUrl}/admin/invites/policy`),
|
||||||
])
|
])
|
||||||
if (!inviteRes.ok) {
|
if (!inviteRes.ok) {
|
||||||
if (handleAuthResponse(inviteRes)) return
|
if (handleAuthResponse(inviteRes)) return
|
||||||
@@ -168,14 +181,24 @@ export default function AdminInviteManagementPage() {
|
|||||||
if (handleAuthResponse(usersRes)) return
|
if (handleAuthResponse(usersRes)) return
|
||||||
throw new Error(`Failed to load users (${usersRes.status})`)
|
throw new Error(`Failed to load users (${usersRes.status})`)
|
||||||
}
|
}
|
||||||
const [inviteData, profileData, usersData] = await Promise.all([
|
if (!policyRes.ok) {
|
||||||
|
if (handleAuthResponse(policyRes)) return
|
||||||
|
throw new Error(`Failed to load invite policy (${policyRes.status})`)
|
||||||
|
}
|
||||||
|
const [inviteData, profileData, usersData, policyData] = await Promise.all([
|
||||||
inviteRes.json(),
|
inviteRes.json(),
|
||||||
profileRes.json(),
|
profileRes.json(),
|
||||||
usersRes.json(),
|
usersRes.json(),
|
||||||
|
policyRes.json(),
|
||||||
])
|
])
|
||||||
|
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
|
||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
|
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
|
||||||
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
|
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
|
||||||
|
setInvitePolicy(nextPolicy)
|
||||||
|
setMasterInviteSelection(
|
||||||
|
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
|
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
|
||||||
if (jellyfinRes.ok) {
|
if (jellyfinRes.ok) {
|
||||||
@@ -482,12 +505,71 @@ export default function AdminInviteManagementPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bulkSetInviteAccess = async (enabled: boolean) => {
|
||||||
|
setBulkInviteAccessBusy(true)
|
||||||
|
setStatus(null)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (handleAuthResponse(response)) return
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Bulk invite access update failed')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setStatus(
|
||||||
|
`${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.`
|
||||||
|
)
|
||||||
|
await loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not update invite access for all users.')
|
||||||
|
} finally {
|
||||||
|
setBulkInviteAccessBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => {
|
||||||
|
const selectedValue =
|
||||||
|
nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || ''
|
||||||
|
setInvitePolicySaving(true)
|
||||||
|
setStatus(null)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/admin/invites/policy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ master_invite_id: selectedValue || null }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (handleAuthResponse(response)) return
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite policy update failed')
|
||||||
|
}
|
||||||
|
setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.')
|
||||||
|
await loadData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not update invite policy.')
|
||||||
|
} finally {
|
||||||
|
setInvitePolicySaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
|
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
|
||||||
const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length
|
const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length
|
||||||
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
|
const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length
|
||||||
|
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length
|
||||||
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
|
const usableInvites = invites.filter((invite) => invite.is_usable !== false).length
|
||||||
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
|
const disabledInvites = invites.filter((invite) => invite.enabled === false).length
|
||||||
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
|
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
|
||||||
|
const masterInvite = invitePolicy?.master_invite ?? null
|
||||||
|
|
||||||
const inviteTraceRows = useMemo(() => {
|
const inviteTraceRows = useMemo(() => {
|
||||||
const inviteByCode = new Map<string, Invite>()
|
const inviteByCode = new Map<string, Invite>()
|
||||||
@@ -663,6 +745,17 @@ export default function AdminInviteManagementPage() {
|
|||||||
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
|
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="invite-admin-summary-row">
|
||||||
|
<span className="label">Self-service invites</span>
|
||||||
|
<div className="invite-admin-summary-row__value">
|
||||||
|
<strong>{inviteAccessEnabledUsers}</strong>
|
||||||
|
<span>
|
||||||
|
{masterInvite
|
||||||
|
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
|
||||||
|
: 'users enabled • no master template set'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="invite-admin-summary-row">
|
<div className="invite-admin-summary-row">
|
||||||
<span className="label">Expiry rules</span>
|
<span className="label">Expiry rules</span>
|
||||||
<div className="invite-admin-summary-row__value">
|
<div className="invite-admin-summary-row__value">
|
||||||
@@ -746,17 +839,83 @@ export default function AdminInviteManagementPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Blanket controls</h2>
|
<h2>Blanket controls</h2>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Apply invite profile defaults or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
|
Apply invite access, master invite template rules, profile defaults, or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-meta-row">
|
<div className="admin-meta-row">
|
||||||
<span>Local non-admin users: {nonAdminUsers.length}</span>
|
<span>Local non-admin users: {nonAdminUsers.length}</span>
|
||||||
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
|
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
|
||||||
|
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
|
||||||
<span>Profile assigned: {profiledUsers}</span>
|
<span>Profile assigned: {profiledUsers}</span>
|
||||||
<span>Custom expiry set: {expiringUsers}</span>
|
<span>Custom expiry set: {expiringUsers}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="user-bulk-groups">
|
<div className="user-bulk-groups">
|
||||||
|
<div className="user-bulk-group">
|
||||||
|
<div className="user-bulk-group-meta">
|
||||||
|
<strong>Self-service invites</strong>
|
||||||
|
<span className="meta">
|
||||||
|
Enable or disable the “My invites” tab for all non-admin users.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void bulkSetInviteAccess(true)}
|
||||||
|
disabled={bulkInviteAccessBusy}
|
||||||
|
>
|
||||||
|
{bulkInviteAccessBusy ? 'Working…' : 'Enable for all users'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => void bulkSetInviteAccess(false)}
|
||||||
|
disabled={bulkInviteAccessBusy}
|
||||||
|
>
|
||||||
|
{bulkInviteAccessBusy ? 'Working…' : 'Disable for all users'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="user-bulk-group">
|
||||||
|
<label className="admin-select">
|
||||||
|
<span>Master invite template</span>
|
||||||
|
<select
|
||||||
|
value={masterInviteSelection}
|
||||||
|
onChange={(e) => setMasterInviteSelection(e.target.value)}
|
||||||
|
disabled={invitePolicySaving}
|
||||||
|
>
|
||||||
|
<option value="">None (users use their own defaults)</option>
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<option key={invite.id} value={invite.id}>
|
||||||
|
{invite.code}
|
||||||
|
{invite.label ? ` - ${invite.label}` : ''}
|
||||||
|
{invite.enabled === false ? ' (disabled)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" onClick={() => void saveMasterInvitePolicy()} disabled={invitePolicySaving}>
|
||||||
|
{invitePolicySaving ? 'Saving…' : 'Save master template'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
setMasterInviteSelection('')
|
||||||
|
void saveMasterInvitePolicy('')
|
||||||
|
}}
|
||||||
|
disabled={invitePolicySaving}
|
||||||
|
>
|
||||||
|
{invitePolicySaving ? 'Saving…' : 'Clear master template'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="user-detail-helper">
|
||||||
|
{masterInvite
|
||||||
|
? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ''}. Self-service invites inherit its limits/status/profile.`
|
||||||
|
: 'No master template set. Self-service invites use each user’s profile/defaults.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="user-bulk-group">
|
<div className="user-bulk-group">
|
||||||
<label className="admin-select">
|
<label className="admin-select">
|
||||||
<span>Profile</span>
|
<span>Profile</span>
|
||||||
|
|||||||
@@ -4899,6 +4899,20 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Profile self-service invite management */
|
/* Profile self-service invite management */
|
||||||
|
.profile-tabbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tab-panel {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-security-form {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-invites-section {
|
.profile-invites-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -4906,7 +4920,7 @@ textarea {
|
|||||||
|
|
||||||
.profile-invites-layout {
|
.profile-invites-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
|
grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -4941,6 +4955,20 @@ textarea {
|
|||||||
color: #d8e2ef;
|
color: #d8e2ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-invite-master-banner code {
|
||||||
|
color: #e6eefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bulk-group-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bulk-group-meta strong {
|
||||||
|
color: #e7edf6;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.profile-invites-layout {
|
.profile-invites-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type ProfileInfo = {
|
|||||||
username: string
|
username: string
|
||||||
role: string
|
role: string
|
||||||
auth_provider: string
|
auth_provider: string
|
||||||
|
invite_management_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileStats = {
|
type ProfileStats = {
|
||||||
@@ -66,6 +67,20 @@ type OwnedInvite = {
|
|||||||
type OwnedInvitesResponse = {
|
type OwnedInvitesResponse = {
|
||||||
invites?: OwnedInvite[]
|
invites?: OwnedInvite[]
|
||||||
count?: number
|
count?: number
|
||||||
|
invite_access?: {
|
||||||
|
enabled?: boolean
|
||||||
|
managed_by_master?: boolean
|
||||||
|
}
|
||||||
|
master_invite?: {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_usable?: boolean
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInviteForm = {
|
type OwnedInviteForm = {
|
||||||
@@ -77,6 +92,8 @@ type OwnedInviteForm = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
||||||
|
|
||||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||||
code: '',
|
code: '',
|
||||||
label: '',
|
label: '',
|
||||||
@@ -117,6 +134,10 @@ export default function ProfilePage() {
|
|||||||
const [inviteSaving, setInviteSaving] = useState(false)
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||||
|
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||||
|
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||||
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
|
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
const signupBaseUrl = useMemo(() => {
|
||||||
@@ -150,10 +171,14 @@ export default function ProfilePage() {
|
|||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
role: user?.role ?? 'user',
|
role: user?.role ?? 'user',
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
|
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||||
})
|
})
|
||||||
setStats(data?.stats ?? null)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load your profile.')
|
setStatus('Could not load your profile.')
|
||||||
@@ -182,17 +207,35 @@ export default function ProfilePage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text()
|
let detail = 'Update failed'
|
||||||
throw new Error(text || 'Update failed')
|
try {
|
||||||
|
const payload = await response.json()
|
||||||
|
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||||||
|
detail = payload.detail
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
const text = await response.text().catch(() => '')
|
||||||
|
if (text?.trim()) detail = text
|
||||||
|
}
|
||||||
|
throw new Error(detail)
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
setCurrentPassword('')
|
setCurrentPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setStatus('Password updated.')
|
setStatus(
|
||||||
|
data?.provider === 'jellyfin'
|
||||||
|
? 'Password updated in Jellyfin (and Magent cache).'
|
||||||
|
: 'Password updated.'
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
setStatus(`Could not update password. ${err.message}`)
|
||||||
|
} else {
|
||||||
setStatus('Could not update password. Check your current password.')
|
setStatus('Could not update password. Check your current password.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetInviteEditor = () => {
|
const resetInviteEditor = () => {
|
||||||
setInviteEditingId(null)
|
setInviteEditingId(null)
|
||||||
@@ -226,6 +269,9 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(data?.master_invite ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
@@ -316,6 +362,22 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authProvider = profile?.auth_provider ?? 'local'
|
||||||
|
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||||
|
const securityHelpText =
|
||||||
|
authProvider === 'jellyfin'
|
||||||
|
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||||||
|
: authProvider === 'local'
|
||||||
|
? 'Change your Magent account password.'
|
||||||
|
: 'Password changes are not available for this sign-in provider.'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'invites' && !canManageInvites) {
|
||||||
|
setActiveTab('overview')
|
||||||
|
}
|
||||||
|
}, [activeTab, canManageInvites])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
}
|
}
|
||||||
@@ -329,8 +391,51 @@ export default function ProfilePage() {
|
|||||||
{profile.auth_provider}.
|
{profile.auth_provider}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="profile-grid">
|
<div className="profile-tabbar">
|
||||||
<section className="profile-section">
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'overview'}
|
||||||
|
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'activity'}
|
||||||
|
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('activity')}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
{canManageInvites ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'invites'}
|
||||||
|
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('invites')}
|
||||||
|
>
|
||||||
|
My invites
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'security'}
|
||||||
|
className={activeTab === 'security' ? 'is-active' : ''}
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Account stats</h2>
|
<h2>Account stats</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -353,6 +458,18 @@ export default function ProfilePage() {
|
|||||||
<div className="stat-label">Declined</div>
|
<div className="stat-label">Declined</div>
|
||||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
<div className="stat-value">{stats?.declined ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Working</div>
|
||||||
|
<div className="stat-value">{stats?.working ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Partial</div>
|
||||||
|
<div className="stat-value">{stats?.partial ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Approved</div>
|
||||||
|
<div className="stat-value">{stats?.approved ?? 0}</div>
|
||||||
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Last request</div>
|
<div className="stat-label">Last request</div>
|
||||||
<div className="stat-value stat-value--small">
|
<div className="stat-value stat-value--small">
|
||||||
@@ -367,6 +484,10 @@ export default function ProfilePage() {
|
|||||||
: '0%'}
|
: '0%'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">Total requests (global)</div>
|
||||||
|
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
||||||
|
</div>
|
||||||
{profile?.role === 'admin' ? (
|
{profile?.role === 'admin' ? (
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Most active user</div>
|
<div className="stat-label">Most active user</div>
|
||||||
@@ -379,7 +500,10 @@ export default function ProfilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="profile-section">
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'activity' && (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Connection history</h2>
|
<h2>Connection history</h2>
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||||||
@@ -390,6 +514,7 @@ export default function ProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
||||||
<div className="meta">IP: {entry.ip}</div>
|
<div className="meta">IP: {entry.ip}</div>
|
||||||
|
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
||||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="connection-count">{entry.hit_count} visits</div>
|
<div className="connection-count">{entry.hit_count} visits</div>
|
||||||
@@ -400,80 +525,34 @@ export default function ProfilePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
)}
|
||||||
<section className="profile-section profile-invites-section">
|
|
||||||
|
{activeTab === 'invites' && (
|
||||||
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||||
<div className="user-directory-panel-header">
|
<div className="user-directory-panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>My invites</h2>
|
<h2>My invites</h2>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
Create and manage invite links you’ve issued. New invites use your account defaults.
|
{inviteManagedByMaster
|
||||||
|
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
||||||
|
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
<div className="profile-invites-layout">
|
<div className="profile-invites-layout">
|
||||||
<div className="profile-invites-list">
|
|
||||||
{invites.length === 0 ? (
|
|
||||||
<div className="status-banner">You haven’t created any invites yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-list">
|
|
||||||
{invites.map((invite) => (
|
|
||||||
<div key={invite.id} className="admin-list-item">
|
|
||||||
<div className="admin-list-item-main">
|
|
||||||
<div className="admin-list-item-title-row">
|
|
||||||
<code className="invite-code">{invite.code}</code>
|
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
||||||
</span>
|
|
||||||
<span className="small-pill is-muted">
|
|
||||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
|
||||||
{invite.description && (
|
|
||||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
|
||||||
{invite.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="admin-meta-row">
|
|
||||||
<span>
|
|
||||||
Uses: {invite.use_count}
|
|
||||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
|
||||||
</span>
|
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
||||||
<span>Created: {formatDate(invite.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => copyInviteLink(invite)}
|
|
||||||
>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => editInvite(invite)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="profile-invite-form-card">
|
<div className="profile-invite-form-card">
|
||||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||||
<p className="meta profile-invite-form-lede">
|
<p className="meta profile-invite-form-lede">
|
||||||
Share the generated signup link with the person you want to invite.
|
Share the generated signup link with the person you want to invite.
|
||||||
</p>
|
</p>
|
||||||
|
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||||
|
<div className="status-banner profile-invite-master-banner">
|
||||||
|
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||||
|
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||||
<div className="invite-form-row">
|
<div className="invite-form-row">
|
||||||
<div className="invite-form-row-label">
|
<div className="invite-form-row-label">
|
||||||
@@ -539,6 +618,7 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
placeholder="Blank = unlimited"
|
placeholder="Blank = unlimited"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -549,6 +629,7 @@ export default function ProfilePage() {
|
|||||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="2026-03-01T12:00:00+00:00"
|
placeholder="2026-03-01T12:00:00+00:00"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -570,6 +651,7 @@ export default function ProfilePage() {
|
|||||||
enabled: event.target.checked,
|
enabled: event.target.checked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
/>
|
/>
|
||||||
Invite is enabled
|
Invite is enabled
|
||||||
</label>
|
</label>
|
||||||
@@ -594,14 +676,72 @@ export default function ProfilePage() {
|
|||||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="profile-invites-list">
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="status-banner">You haven’t created any invites yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div key={invite.id} className="admin-list-item">
|
||||||
|
<div className="admin-list-item-main">
|
||||||
|
<div className="admin-list-item-title-row">
|
||||||
|
<code className="invite-code">{invite.code}</code>
|
||||||
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
|
<span className="small-pill is-muted">
|
||||||
|
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||||
|
{invite.description && (
|
||||||
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
|
{invite.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-meta-row">
|
||||||
|
<span>
|
||||||
|
Uses: {invite.use_count}
|
||||||
|
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
|
<span>Created: {formatDate(invite.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => copyInviteLink(invite)}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => editInvite(invite)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{profile?.auth_provider !== 'local' ? (
|
)}
|
||||||
<div className="status-banner">
|
|
||||||
Password changes are only available for local Magent accounts.
|
{activeTab === 'security' && (
|
||||||
</div>
|
<section className="profile-section profile-tab-panel">
|
||||||
) : (
|
<h2>Security</h2>
|
||||||
<form onSubmit={submit} className="auth-form">
|
<div className="status-banner">{securityHelpText}</div>
|
||||||
|
{canChangePassword ? (
|
||||||
|
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||||
<label>
|
<label>
|
||||||
Current password
|
Current password
|
||||||
<input
|
<input
|
||||||
@@ -622,9 +762,17 @@ export default function ProfilePage() {
|
|||||||
</label>
|
</label>
|
||||||
{status && <div className="status-banner">{status}</div>}
|
{status && <div className="status-banner">{status}</div>}
|
||||||
<div className="auth-actions">
|
<div className="auth-actions">
|
||||||
<button type="submit">Update password</button>
|
<button type="submit">
|
||||||
|
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="status-banner">
|
||||||
|
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type AdminUser = {
|
|||||||
last_login_at?: string | null
|
last_login_at?: string | null
|
||||||
is_blocked?: boolean
|
is_blocked?: boolean
|
||||||
auto_search_enabled?: boolean
|
auto_search_enabled?: boolean
|
||||||
|
invite_management_enabled?: boolean
|
||||||
jellyseerr_user_id?: number | null
|
jellyseerr_user_id?: number | null
|
||||||
profile_id?: number | null
|
profile_id?: number | null
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
@@ -240,6 +241,30 @@ export default function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateInviteManagementEnabled = async (enabled: boolean) => {
|
||||||
|
if (!user) return
|
||||||
|
try {
|
||||||
|
setActionStatus(null)
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Update failed')
|
||||||
|
}
|
||||||
|
await loadUser()
|
||||||
|
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Could not update invite access.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyProfileToUser = async (profileOverride?: string | null) => {
|
const applyProfileToUser = async (profileOverride?: string | null) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
const profileValue = profileOverride ?? profileSelection
|
const profileValue = profileOverride ?? profileSelection
|
||||||
@@ -539,6 +564,15 @@ export default function UserDetailPage() {
|
|||||||
/>
|
/>
|
||||||
<span>Allow auto search/download</span>
|
<span>Allow auto search/download</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(user.invite_management_enabled ?? false)}
|
||||||
|
disabled={user.role === 'admin'}
|
||||||
|
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Allow self-service invites</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
@@ -571,7 +605,7 @@ export default function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' && (
|
||||||
<div className="user-detail-helper">
|
<div className="user-detail-helper">
|
||||||
Admins always have auto search/download access.
|
Admins always have auto search/download and invite-management access.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user