feat(proj): init
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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<String, dynamic> 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<String, dynamic> 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<AuthProviders> 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<String> 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<String?> showYandexLogin({
|
||||
required BuildContext context,
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
required Future<String> Function(String code) exchangeCode,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<String>(
|
||||
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<String> Function(String code) exchangeCode;
|
||||
|
||||
@override
|
||||
State<YandexLoginScreen> createState() => _YandexLoginScreenState();
|
||||
}
|
||||
|
||||
class _YandexLoginScreenState extends State<YandexLoginScreen> {
|
||||
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<void> _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<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
|
||||
if (await _tryFinish(request.url)) {
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
Future<bool> _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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user