feat:auth

This commit is contained in:
Benexl
2025-07-14 22:14:07 +03:00
parent f8992d46dd
commit f4c4c874df
5 changed files with 464 additions and 1 deletions

View 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)

View File

@@ -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

View File

@@ -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!"

View File

@@ -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
View 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())