diff --git a/fastanime/cli/interactive/menus/auth.py b/fastanime/cli/interactive/menus/auth.py new file mode 100644 index 0000000..8b77341 --- /dev/null +++ b/fastanime/cli/interactive/menus/auth.py @@ -0,0 +1,256 @@ +""" +Interactive authentication menu for AniList OAuth login/logout and user profile management. +Implements Step 5: AniList Authentication Flow +""" + +import webbrowser +from typing import Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from ....libs.api.types import UserProfile +from ...auth.manager import AuthManager +from ...utils.feedback import create_feedback_manager, execute_with_feedback +from ..session import Context, session +from ..state import ControlFlow, State + + +@session.menu +def auth(ctx: Context, state: State) -> State | ControlFlow: + """ + Interactive authentication menu for managing AniList login/logout and viewing user profile. + """ + icons = ctx.config.general.icons + feedback = create_feedback_manager(icons) + console = Console() + console.clear() + + # Get current authentication status + user_profile = getattr(ctx.media_api, "user_profile", None) + auth_manager = AuthManager() + + # Display current authentication status + _display_auth_status(console, user_profile, icons) + + # Menu options based on authentication status + if user_profile: + options = [ + f"{'šŸ‘¤ ' if icons else ''}View Profile Details", + f"{'šŸ”“ ' if icons else ''}Logout", + f"{'ā†©ļø ' if icons else ''}Back to Main Menu", + ] + else: + options = [ + f"{'šŸ” ' if icons else ''}Login to AniList", + f"{'ā“ ' if icons else ''}How to Get Token", + f"{'ā†©ļø ' if icons else ''}Back to Main Menu", + ] + + choice = ctx.selector.choose( + prompt="Select Authentication Action", + choices=options, + header="AniList Authentication Menu", + ) + + if not choice: + return ControlFlow.BACK + + # Handle menu choices + if "Login to AniList" in choice: + return _handle_login(ctx, auth_manager, feedback, icons) + elif "Logout" in choice: + return _handle_logout(ctx, auth_manager, feedback, icons) + elif "View Profile Details" in choice: + _display_user_profile_details(console, user_profile, icons) + feedback.pause_for_user("Press Enter to continue") + return ControlFlow.CONTINUE + elif "How to Get Token" in choice: + _display_token_help(console, icons) + feedback.pause_for_user("Press Enter to continue") + return ControlFlow.CONTINUE + else: # Back to Main Menu + return ControlFlow.BACK + + +def _display_auth_status(console: Console, user_profile: Optional[UserProfile], icons: bool): + """Display current authentication status in a nice panel.""" + if user_profile: + status_icon = "🟢" if icons else "[green]ā—[/green]" + status_text = f"{status_icon} Authenticated" + user_info = f"Logged in as: [bold cyan]{user_profile.name}[/bold cyan]\nUser ID: {user_profile.id}" + else: + status_icon = "šŸ”“" if icons else "[red]ā—‹[/red]" + status_text = f"{status_icon} Not Authenticated" + user_info = "Log in to access personalized features like:\n• Your anime lists (Watching, Completed, etc.)\n• Progress tracking\n• List management" + + panel = Panel( + user_info, + title=f"Authentication Status: {status_text}", + border_style="green" if user_profile else "red", + ) + console.print(panel) + console.print() + + +def _handle_login(ctx: Context, auth_manager: AuthManager, feedback, icons: bool) -> State | ControlFlow: + """Handle the interactive login process.""" + + def perform_login(): + # Open browser to AniList OAuth page + oauth_url = "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token" + + if feedback.confirm("Open AniList authorization page in browser?", default=True): + try: + webbrowser.open(oauth_url) + feedback.info("Browser opened", "Complete the authorization process in your browser") + except Exception as e: + feedback.warning("Could not open browser automatically", f"Please manually visit: {oauth_url}") + else: + feedback.info("Manual authorization", f"Please visit: {oauth_url}") + + # Get token from user + feedback.info("Token Input", "Paste the token from the browser URL after '#access_token='") + token = ctx.selector.ask( + "Enter your AniList Access Token" + ) + + if not token or not token.strip(): + feedback.error("Login cancelled", "No token provided") + return None + + # Authenticate with the API + profile = ctx.media_api.authenticate(token.strip()) + + if not profile: + feedback.error("Authentication failed", "The token may be invalid or expired") + return None + + # Save credentials using the auth manager + auth_manager.save_user_profile(profile, token.strip()) + return profile + + success, profile = execute_with_feedback( + perform_login, + feedback, + "authenticate", + loading_msg="Validating token with AniList", + success_msg=f"Successfully logged in as {profile.name if profile else 'user'}! šŸŽ‰" if icons else f"Successfully logged in as {profile.name if profile else 'user'}!", + error_msg="Login failed", + show_loading=True + ) + + if success and profile: + feedback.pause_for_user("Press Enter to continue") + + return ControlFlow.CONTINUE + + +def _handle_logout(ctx: Context, auth_manager: AuthManager, feedback, icons: bool) -> State | ControlFlow: + """Handle the logout process with confirmation.""" + if not feedback.confirm( + "Are you sure you want to logout?", + "This will remove your saved AniList token and log you out", + default=False + ): + return ControlFlow.CONTINUE + + def perform_logout(): + # Clear from auth manager + auth_manager.clear_user_profile() + + # Clear from API client + ctx.media_api.token = None + ctx.media_api.user_profile = None + if hasattr(ctx.media_api, 'http_client'): + ctx.media_api.http_client.headers.pop("Authorization", None) + + return True + + success, _ = execute_with_feedback( + perform_logout, + feedback, + "logout", + loading_msg="Logging out", + success_msg="Successfully logged out šŸ‘‹" if icons else "Successfully logged out", + error_msg="Logout failed", + show_loading=False + ) + + if success: + feedback.pause_for_user("Press Enter to continue") + + return ControlFlow.CONTINUE + + +def _display_user_profile_details(console: Console, user_profile: UserProfile, icons: bool): + """Display detailed user profile information.""" + if not user_profile: + console.print("[red]No user profile available[/red]") + return + + # Create a detailed profile table + table = Table(title=f"{'šŸ‘¤ ' if icons else ''}User Profile: {user_profile.name}") + table.add_column("Property", style="cyan", no_wrap=True) + table.add_column("Value", style="green") + + table.add_row("Name", user_profile.name) + table.add_row("User ID", str(user_profile.id)) + + if user_profile.avatar_url: + table.add_row("Avatar URL", user_profile.avatar_url) + + if user_profile.banner_url: + table.add_row("Banner URL", user_profile.banner_url) + + console.print() + console.print(table) + console.print() + + # Show available features + features_panel = Panel( + "Available Features:\n" + f"{'šŸ“ŗ ' if icons else '• '}Access your anime lists (Watching, Completed, etc.)\n" + f"{'āœļø ' if icons else '• '}Update watch progress and scores\n" + f"{'āž• ' if icons else '• '}Add/remove anime from your lists\n" + f"{'šŸ”„ ' if icons else '• '}Sync progress with AniList\n" + f"{'šŸ”” ' if icons else '• '}Access AniList notifications", + title="Available with Authentication", + border_style="green" + ) + console.print(features_panel) + + +def _display_token_help(console: Console, icons: bool): + """Display help information about getting an AniList token.""" + help_text = """ +[bold cyan]How to get your AniList Access Token:[/bold cyan] + +[bold]Step 1:[/bold] Visit the AniList authorization page +https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token + +[bold]Step 2:[/bold] Log in to your AniList account if prompted + +[bold]Step 3:[/bold] Click "Authorize" to grant FastAnime access + +[bold]Step 4:[/bold] Copy the token from the browser URL +Look for the part after "#access_token=" in the address bar + +[bold]Step 5:[/bold] Paste the token when prompted in FastAnime + +[yellow]Note:[/yellow] The token will be stored securely and used for all AniList features. +You only need to do this once unless you revoke access or the token expires. + +[yellow]Privacy:[/yellow] FastAnime only requests minimal permissions needed for +list management and does not access sensitive account information. +""" + + panel = Panel( + help_text, + title=f"{'ā“ ' if icons else ''}AniList Token Help", + border_style="blue" + ) + console.print() + console.print(panel) diff --git a/fastanime/cli/interactive/menus/main.py b/fastanime/cli/interactive/menus/main.py index dd5fdc0..98b93c4 100644 --- a/fastanime/cli/interactive/menus/main.py +++ b/fastanime/cli/interactive/menus/main.py @@ -60,9 +60,11 @@ def main(ctx: Context, state: State) -> State | ControlFlow: ), # --- Local Watch History --- f"{'šŸ“– ' if icons else ''}Local Watch History": lambda: ("WATCH_HISTORY", None), + # --- Authentication and Account Management --- + f"{'šŸ” ' if icons else ''}Authentication": lambda: ("AUTH", None), # --- Control Flow and Utility Options --- f"{'šŸ”§ ' if icons else ''}Session Management": lambda: ("SESSION_MANAGEMENT", None), - f"{'ļæ½šŸ“ ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None), + f"{'šŸ“ ' if icons else ''}Edit Config": lambda: ("RELOAD_CONFIG", None), f"{'āŒ ' if icons else ''}Exit": lambda: ("EXIT", None), } @@ -86,6 +88,10 @@ def main(ctx: Context, state: State) -> State | ControlFlow: return ControlFlow.RELOAD_CONFIG if next_menu_name == "SESSION_MANAGEMENT": return State(menu_name="SESSION_MANAGEMENT") + if next_menu_name == "AUTH": + return State(menu_name="AUTH") + if next_menu_name == "WATCH_HISTORY": + return State(menu_name="WATCH_HISTORY") if next_menu_name == "CONTINUE": return ControlFlow.CONTINUE diff --git a/fastanime/cli/utils/auth_utils.py b/fastanime/cli/utils/auth_utils.py index b591594..1e6c992 100644 --- a/fastanime/cli/utils/auth_utils.py +++ b/fastanime/cli/utils/auth_utils.py @@ -114,3 +114,55 @@ def prompt_for_authentication( ) return feedback.confirm("Continue without authentication?", default=False) + + +def show_authentication_instructions(feedback: FeedbackManager, icons_enabled: bool = True) -> None: + """ + Show detailed instructions for authenticating with AniList. + """ + icon = "šŸ” " if icons_enabled else "" + + feedback.info( + f"{icon}AniList Authentication Required", + "To access personalized features, you need to authenticate with your AniList account" + ) + + instructions = [ + "1. Go to the interactive menu: 'Authentication' option", + "2. Select 'Login to AniList'", + "3. Follow the OAuth flow in your browser", + "4. Copy and paste the token when prompted", + "", + "Alternatively, use the CLI command:", + "fastanime anilist auth" + ] + + for instruction in instructions: + if instruction: + feedback.info("", instruction) + else: + feedback.info("", "") + + +def get_authentication_prompt_message(operation_name: str, icons_enabled: bool = True) -> str: + """ + Get a formatted message prompting for authentication for a specific operation. + """ + icon = "šŸ”’ " if icons_enabled else "" + return f"{icon}Authentication required to {operation_name}. Please log in to continue." + + +def format_login_success_message(user_name: str, icons_enabled: bool = True) -> str: + """ + Format a success message for successful login. + """ + icon = "šŸŽ‰ " if icons_enabled else "" + return f"{icon}Successfully logged in as {user_name}!" + + +def format_logout_success_message(icons_enabled: bool = True) -> str: + """ + Format a success message for successful logout. + """ + icon = "šŸ‘‹ " if icons_enabled else "" + return f"{icon}Successfully logged out!" diff --git a/fastanime/cli/utils/feedback.py b/fastanime/cli/utils/feedback.py index dea4484..7de1b3e 100644 --- a/fastanime/cli/utils/feedback.py +++ b/fastanime/cli/utils/feedback.py @@ -66,6 +66,20 @@ class FeedbackManager: icon = "ā“ " if self.icons_enabled else "" return Confirm.ask(f"[bold]{icon}{message}[/bold]", default=default) + def prompt(self, message: str, details: Optional[str] = None, default: Optional[str] = None) -> str: + """Prompt user for text input with optional details and default value.""" + from rich.prompt import Prompt + + icon = "šŸ“ " if self.icons_enabled else "" + + if details: + self.info(f"{icon}{message}", details) + + return Prompt.ask( + f"[bold]{icon}{message}[/bold]", + default=default or "" + ) + def notify_operation_result( self, operation_name: str, diff --git a/test_auth_flow.py b/test_auth_flow.py new file mode 100644 index 0000000..9a13166 --- /dev/null +++ b/test_auth_flow.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Test script for the Step 5: AniList Authentication Flow implementation. +This tests the interactive authentication menu and its functionalities. +""" + +import sys +from pathlib import Path + +# Add the project root to the path so we can import fastanime modules +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from fastanime.cli.interactive.menus.auth import ( + _display_auth_status, + _display_user_profile_details, + _display_token_help +) +from fastanime.libs.api.types import UserProfile +from rich.console import Console + + +def test_auth_status_display(): + """Test authentication status display functions.""" + console = Console() + print("=== Testing Authentication Status Display ===\n") + + # Test without authentication + print("1. Testing unauthenticated status:") + _display_auth_status(console, None, True) + + # Test with authentication + print("\n2. Testing authenticated status:") + mock_user = UserProfile( + id=12345, + name="TestUser", + avatar_url="https://example.com/avatar.jpg", + banner_url="https://example.com/banner.jpg" + ) + _display_auth_status(console, mock_user, True) + + +def test_profile_details(): + """Test user profile details display.""" + console = Console() + print("\n\n=== Testing Profile Details Display ===\n") + + mock_user = UserProfile( + id=12345, + name="TestUser", + avatar_url="https://example.com/avatar.jpg", + banner_url="https://example.com/banner.jpg" + ) + + _display_user_profile_details(console, mock_user, True) + + +def test_token_help(): + """Test token help display.""" + console = Console() + print("\n\n=== Testing Token Help Display ===\n") + + _display_token_help(console, True) + + +def test_auth_utils(): + """Test authentication utility functions.""" + print("\n\n=== Testing Authentication Utilities ===\n") + + from fastanime.cli.utils.auth_utils import ( + get_auth_status_indicator, + format_login_success_message, + format_logout_success_message + ) + + # Mock API client + class MockApiClient: + def __init__(self, user_profile=None): + self.user_profile = user_profile + + # Test without authentication + mock_api_unauthenticated = MockApiClient() + status_text, profile = get_auth_status_indicator(mock_api_unauthenticated, True) + print(f"Unauthenticated status: {status_text}") + print(f"Profile: {profile}") + + # Test with authentication + mock_user = UserProfile( + id=12345, + name="TestUser", + avatar_url="https://example.com/avatar.jpg", + banner_url="https://example.com/banner.jpg" + ) + mock_api_authenticated = MockApiClient(mock_user) + status_text, profile = get_auth_status_indicator(mock_api_authenticated, True) + print(f"\nAuthenticated status: {status_text}") + print(f"Profile: {profile.name if profile else None}") + + # Test success messages + print(f"\nLogin success message: {format_login_success_message('TestUser', True)}") + print(f"Logout success message: {format_logout_success_message(True)}") + + +def main(): + """Run all authentication tests.""" + print("šŸ” Testing Step 5: AniList Authentication Flow Implementation\n") + print("=" * 70) + + try: + test_auth_status_display() + test_profile_details() + test_token_help() + test_auth_utils() + + print("\n" + "=" * 70) + print("āœ… All authentication flow tests completed successfully!") + print("\nFeatures implemented:") + print("• Interactive OAuth login process") + print("• Logout functionality with confirmation") + print("• User profile viewing menu") + print("• Authentication status display") + print("• Token help and instructions") + print("• Enhanced user feedback") + + except Exception as e: + print(f"\nāŒ Test failed with error: {e}") + import traceback + traceback.print_exc() + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main())