mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat:auth
This commit is contained in:
256
fastanime/cli/interactive/menus/auth.py
Normal file
256
fastanime/cli/interactive/menus/auth.py
Normal file
@@ -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)
|
||||
@@ -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"{'<EFBFBD>📝 ' 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
|
||||
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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,
|
||||
|
||||
135
test_auth_flow.py
Normal file
135
test_auth_flow.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user