diff --git a/.build_number b/.build_number index 31d0323..6f9e668 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602262030 +2602262049 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index d117f32..70479ad 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602262030" +BUILD_NUMBER = "2602262049" 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' diff --git a/backend/app/db.py b/backend/app/db.py index b24fe38..b7867c2 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1162,12 +1162,94 @@ def increment_signup_invite_use(invite_id: int) -> None: def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]: - user = get_user_by_username(username) - if not user: + # Resolve case-insensitive duplicates safely by only considering local-provider rows. + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + created_at, last_login_at, is_blocked, auto_search_enabled, + invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, + jellyfin_password_hash, last_jellyfin_auth_at + FROM users + WHERE username = ? COLLATE NOCASE + ORDER BY + CASE WHEN username = ? THEN 0 ELSE 1 END, + id ASC + """, + (username, username), + ).fetchall() + if not rows: return None - if not verify_password(password, user["password_hash"]): - return None - return user + for row in rows: + provider = str(row[4] or "local").lower() + if provider != "local": + continue + if not verify_password(password, row[2]): + continue + return { + "id": row[0], + "username": row[1], + "password_hash": row[2], + "role": row[3], + "auth_provider": row[4], + "jellyseerr_user_id": row[5], + "created_at": row[6], + "last_login_at": row[7], + "is_blocked": bool(row[8]), + "auto_search_enabled": bool(row[9]), + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), + "jellyfin_password_hash": row[15], + "last_jellyfin_auth_at": row[16], + } + return None + + +def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, + created_at, last_login_at, is_blocked, auto_search_enabled, + invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, + jellyfin_password_hash, last_jellyfin_auth_at + FROM users + WHERE username = ? COLLATE NOCASE + ORDER BY + CASE WHEN username = ? THEN 0 ELSE 1 END, + id ASC + """, + (username, username), + ).fetchall() + results: list[Dict[str, Any]] = [] + for row in rows: + results.append( + { + "id": row[0], + "username": row[1], + "password_hash": row[2], + "role": row[3], + "auth_provider": row[4], + "jellyseerr_user_id": row[5], + "created_at": row[6], + "last_login_at": row[7], + "is_blocked": bool(row[8]), + "auto_search_enabled": bool(row[9]), + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), + "jellyfin_password_hash": row[15], + "last_jellyfin_auth_at": row[16], + } + ) + return results def set_user_password(username: str, password: str) -> None: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a1f4f05..037d602 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -15,6 +15,7 @@ from ..db import ( create_user_if_missing, set_last_login, get_user_by_username, + get_users_by_username_ci, set_user_password, set_jellyfin_auth_cache, set_user_jellyseerr_id, @@ -447,6 +448,19 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s @router.post("/login") async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict: _enforce_login_rate_limit(request, form_data.username) + # Provider placeholder passwords must never be accepted by the local-login endpoint. + if form_data.password in {"jellyfin-user", "jellyseerr-user"}: + _record_login_failure(request, form_data.username) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + matching_users = get_users_by_username_ci(form_data.username) + has_external_match = any( + str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users + ) + if has_external_match: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="This account uses external sign-in. Use the external sign-in option.", + ) user = verify_user_password(form_data.username, form_data.password) if not user: _record_login_failure(request, form_data.username) diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index fbcdda7..2b1ab5d 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -20,6 +20,8 @@ type ServiceOptions = { const SECTION_LABELS: Record = { magent: 'Magent', + general: 'General', + notifications: 'Notifications', jellyseerr: 'Jellyseerr', jellyfin: 'Jellyfin', artwork: 'Artwork cache', @@ -82,7 +84,11 @@ const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const SECTION_DESCRIPTIONS: Record = { magent: - 'Application-level Magent settings including proxy, binding, TLS, and notification channels.', + 'Magent service settings. Runtime and notification controls are organized under General and Notifications.', + general: + 'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.', + notifications: + 'Notification providers and delivery channel settings used by Magent messaging features.', jellyseerr: 'Connect the request system where users submit content.', jellyfin: 'Control Jellyfin login and availability checks.', artwork: 'Cache posters/backdrops and review artwork coverage.', @@ -98,6 +104,8 @@ const SECTION_DESCRIPTIONS: Record = { const SETTINGS_SECTION_MAP: Record = { magent: 'magent', + general: 'magent', + notifications: 'magent', jellyseerr: 'jellyseerr', jellyfin: 'jellyfin', artwork: null, @@ -214,6 +222,17 @@ const MAGENT_SECTION_GROUPS: Array<{ }, ] +const MAGENT_GROUPS_BY_SECTION: Record> = { + general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']), + notifications: new Set([ + 'magent-notify-core', + 'magent-notify-email', + 'magent-notify-discord', + 'magent-notify-telegram', + 'magent-notify-push', + ]), +} + const SETTING_LABEL_OVERRIDES: Record = { magent_application_url: 'Application URL', magent_application_port: 'Application port', @@ -474,6 +493,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { }, [settings]) const settingsSection = SETTINGS_SECTION_MAP[section] ?? null + const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications' const visibleSections = settingsSection ? [settingsSection] : [] const isCacheSection = section === 'cache' const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source']) @@ -502,18 +522,20 @@ export default function SettingsPage({ section }: SettingsPageProps) { { key: 'cache', title: 'Cache control', items: cacheSettings }, { key: 'artwork', title: 'Artwork cache', items: artworkSettings }, ] - : section === 'magent' + : isMagentGroupedSection ? (() => { + if (section === 'magent') { + return [] + } const magentItems = groupedSettings.magent ?? [] const byKey = new Map(magentItems.map((item) => [item.key, item])) - const used = new Set() - const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => { + const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set() + const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) => + allowedGroupKeys.has(group.key), + ).map((group) => { const items = group.keys .map((key) => byKey.get(key)) .filter((item): item is AdminSetting => Boolean(item)) - for (const item of items) { - used.add(item.key) - } return { key: group.key, title: group.title, @@ -521,15 +543,6 @@ export default function SettingsPage({ section }: SettingsPageProps) { items, } }) - const remaining = magentItems.filter((item) => !used.has(item.key)) - if (remaining.length) { - groups.push({ - key: 'magent-other', - title: 'Additional Magent settings', - description: 'Uncategorized Magent settings.', - items: remaining, - }) - } return groups })() : visibleSections.map((sectionKey) => ({ @@ -1320,12 +1333,12 @@ export default function SettingsPage({ section }: SettingsPageProps) { )} {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && - (!settingsSection || section === 'magent') && ( + (!settingsSection || isMagentGroupedSection) && (

{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}

)} - {section === 'magent' && sectionGroup.key === 'magent-runtime' && ( + {section === 'general' && sectionGroup.key === 'magent-runtime' && (
Runtime host/port and SSL values are configuration settings. Container/process restarts may still be required before bind/port changes take effect. @@ -1845,7 +1858,9 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) : (
- No settings to show here yet. Try the Cache Control page for artwork and saved-request controls. + {section === 'magent' + ? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.' + : 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
)} {showLogs && ( diff --git a/frontend/app/admin/[section]/page.tsx b/frontend/app/admin/[section]/page.tsx index c172954..7483b0b 100644 --- a/frontend/app/admin/[section]/page.tsx +++ b/frontend/app/admin/[section]/page.tsx @@ -14,6 +14,8 @@ const ALLOWED_SECTIONS = new Set([ 'logs', 'maintenance', 'magent', + 'general', + 'notifications', 'site', ]) diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx index fd7f615..41fc318 100644 --- a/frontend/app/ui/AdminSidebar.tsx +++ b/frontend/app/ui/AdminSidebar.tsx @@ -6,6 +6,7 @@ const NAV_GROUPS = [ { title: 'Services', items: [ + { href: '/admin/magent', label: 'Magent' }, { href: '/admin/jellyseerr', label: 'Jellyseerr' }, { href: '/admin/jellyfin', label: 'Jellyfin' }, { href: '/admin/sonarr', label: 'Sonarr' }, @@ -25,7 +26,8 @@ const NAV_GROUPS = [ { title: 'Admin', items: [ - { href: '/admin/magent', label: 'Magent' }, + { href: '/admin/general', label: 'General' }, + { href: '/admin/notifications', label: 'Notifications' }, { href: '/admin/site', label: 'Site' }, { href: '/users', label: 'Users' }, { href: '/admin/invites', label: 'Invite management' },