import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:please_pay_me/data/api/api_client.dart'; import 'package:please_pay_me/data/models/auth_user.dart'; import 'package:please_pay_me/data/models/json.dart'; import 'package:please_pay_me/features/auth/telegram_login.dart'; import 'package:please_pay_me/theme/theme.dart'; import 'package:please_pay_me/ui/ui.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter_android/webview_flutter_android.dart'; const yandexAuthorizeHost = 'oauth.yandex.ru'; /// Same platforms as Telegram WebView login. bool get isYandexWebLoginSupported => isTelegramWebLoginSupported; class YandexAuthProvider { const YandexAuthProvider({ required this.enabled, required this.clientId, this.redirectUri, }); final bool enabled; final String clientId; final String? redirectUri; bool get usable => enabled && clientId.isNotEmpty; factory YandexAuthProvider.fromJson(Map json) { return YandexAuthProvider( enabled: asBool(json['enabled']), clientId: asString(json['client_id']), redirectUri: asStringOrNull(json['redirect_uri']), ); } } class AuthProviders { const AuthProviders({this.yandex}); final YandexAuthProvider? yandex; factory AuthProviders.fromJson(Map json) { final raw = json['yandex']; if (raw is! Map) return const AuthProviders(); return AuthProviders(yandex: YandexAuthProvider.fromJson(asMap(raw))); } } class YandexOAuthCallback { const YandexOAuthCallback({this.code, this.error}); final String? code; final String? error; } Uri yandexAuthorizeUri({ required String clientId, required String redirectUri, }) { return Uri.https(yandexAuthorizeHost, '/authorize', { 'response_type': 'code', 'client_id': clientId, 'redirect_uri': redirectUri, 'force_confirm': 'yes', }); } /// Callback registered in the Yandex app: origin + trailing slash. String cabinetYandexRedirectUri(String cabinetUrl, {String? configured}) { if (configured != null && configured.isNotEmpty) { final parsed = Uri.tryParse(configured); if (parsed != null && parsed.host.toLowerCase() == Uri.parse(resolveCabinetOrigin(cabinetUrl)).host.toLowerCase()) { return configured; } } return '${resolveCabinetOrigin(cabinetUrl)}/'; } /// @deprecated use [cabinetYandexRedirectUri] String cabinetLoginRedirectUri(String cabinetUrl) => cabinetYandexRedirectUri(cabinetUrl); /// True when [uri] is the registered cabinet callback (code or error). YandexOAuthCallback? parseYandexCallback(Uri uri, {required String redirectUri}) { final expected = Uri.tryParse(redirectUri); if (expected == null) return null; if (uri.host.toLowerCase() != expected.host.toLowerCase()) return null; final expectedPath = expected.path.isEmpty ? '/' : expected.path; if (_normalizePath(uri.path) != _normalizePath(expectedPath)) return null; final code = uri.queryParameters['code']?.trim(); final error = uri.queryParameters['error_description']?.trim() ?? uri.queryParameters['error']?.trim(); if ((code == null || code.isEmpty) && (error == null || error.isEmpty)) { return null; } return YandexOAuthCallback( code: code == null || code.isEmpty ? null : code, error: error == null || error.isEmpty ? null : error, ); } String _normalizePath(String path) { if (path.isEmpty) return '/'; return path.length > 1 && path.endsWith('/') ? path.substring(0, path.length - 1) : path; } Future fetchAuthProviders( String apiBaseUrl, { http.Client? httpClient, }) async { final client = ApiClient( baseUrl: apiBaseUrl, tokenProvider: () => null, httpClient: httpClient, ); return AuthProviders.fromJson(await client.getJson('/api/auth/providers')); } Future exchangeYandexCode({ required String apiBaseUrl, required String code, required String redirectUri, http.Client? httpClient, }) async { final client = ApiClient( baseUrl: apiBaseUrl, tokenProvider: () => null, httpClient: httpClient, ); final session = AuthSession.fromJson( await client.postJson( '/api/auth/yandex', body: {'code': code, 'redirect_uri': redirectUri}, ), ); return session.accessToken; } /// Opens Yandex OAuth in a WebView, intercepts the cabinet callback, exchanges /// the code on the API and returns a JWT. Future showYandexLogin({ required BuildContext context, required String clientId, required String redirectUri, required Future Function(String code) exchangeCode, }) { return Navigator.of(context, rootNavigator: true).push( CupertinoPageRoute( fullscreenDialog: true, builder: (_) => YandexLoginScreen( clientId: clientId, redirectUri: redirectUri, exchangeCode: exchangeCode, ), ), ); } class YandexLoginScreen extends StatefulWidget { const YandexLoginScreen({ super.key, required this.clientId, required this.redirectUri, required this.exchangeCode, }); final String clientId; final String redirectUri; final Future Function(String code) exchangeCode; @override State createState() => _YandexLoginScreenState(); } class _YandexLoginScreenState extends State { late final WebViewController _controller; bool _loading = true; bool _completed = false; String? _error; Uri get _startUri => yandexAuthorizeUri( clientId: widget.clientId, redirectUri: widget.redirectUri, ); @override void initState() { super.initState(); _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setUserAgent(telegramWebViewUserAgent) ..setBackgroundColor(const Color(0x00000000)) ..setNavigationDelegate( NavigationDelegate( onNavigationRequest: _onNavigationRequest, onPageStarted: (url) { if (mounted) setState(() => _loading = true); _tryFinish(url); }, onPageFinished: (_) { if (mounted) setState(() => _loading = false); }, onWebResourceError: (error) { if (!mounted || _completed) return; if (error.isForMainFrame == false) return; setState(() { _loading = false; _error = error.description; }); }, ), ); _configureAndroid(); _controller.loadRequest(_startUri); } Future _configureAndroid() async { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; final platform = _controller.platform; if (platform is! AndroidWebViewController) return; final cookies = AndroidWebViewCookieManager( const PlatformWebViewCookieManagerCreationParams(), ); await cookies.setAcceptThirdPartyCookies(platform, true); } Future _onNavigationRequest(NavigationRequest request) async { if (await _tryFinish(request.url)) { return NavigationDecision.prevent; } return NavigationDecision.navigate; } Future _tryFinish(String url) async { if (_completed) return true; final uri = Uri.tryParse(url); if (uri == null) return false; final callback = parseYandexCallback(uri, redirectUri: widget.redirectUri); if (callback == null) return false; if (callback.error != null) { _completed = true; if (mounted) { setState(() { _loading = false; _error = callback.error; }); } return true; } final code = callback.code; if (code == null) return false; _completed = true; if (mounted) setState(() => _loading = true); try { final token = await widget.exchangeCode(code); if (!mounted) return true; Navigator.of(context).pop(token); } on ApiException catch (error) { if (!mounted) return true; setState(() { _loading = false; _error = error.message; _completed = false; }); } return true; } @override Widget build(BuildContext context) { return CupertinoPageScaffold( backgroundColor: AppColors.of(context, AppColors.groupedBackground), navigationBar: AppNavBar( title: 'Вход через Яндекс', subtitle: yandexAuthorizeHost, leading: CupertinoButton( padding: EdgeInsets.zero, minimumSize: Size.zero, onPressed: () => Navigator.of(context).pop(), child: const AppText.body('Закрыть', color: AppColors.accent), ), trailing: CupertinoButton( padding: EdgeInsets.zero, minimumSize: Size.zero, onPressed: () { setState(() { _error = null; _completed = false; }); _controller.loadRequest(_startUri); }, child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent), ), ), child: SafeArea( child: _error != null ? AppErrorView( message: _error!, onRetry: () { setState(() { _error = null; _completed = false; }); _controller.loadRequest(_startUri); }, ) : Stack( children: [ WebViewWidget(controller: _controller), if (_loading) const Center(child: AppSpinner()), ], ), ), ); } }