feat(proj): init
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/branding/app_brand.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/features/auth/session_controller.dart';
|
||||
import 'package:please_pay_me/features/auth/telegram_login.dart';
|
||||
import 'package:please_pay_me/features/auth/yandex_login.dart';
|
||||
import 'package:please_pay_me/features/legal/legal_consent.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Opens the cabinet and returns the harvested JWT.
|
||||
typedef TelegramLoginLauncher = Future<String?> Function(
|
||||
BuildContext context,
|
||||
String cabinetUrl,
|
||||
);
|
||||
|
||||
/// Opens Yandex OAuth and returns the JWT issued by `/api/auth/yandex`.
|
||||
typedef YandexLoginLauncher = Future<String?> Function(
|
||||
BuildContext context, {
|
||||
required String clientId,
|
||||
required String redirectUri,
|
||||
});
|
||||
|
||||
/// Sign-in screen.
|
||||
///
|
||||
/// Cabinet / API origin comes from `.env` (`PPM_WEB_URL`, `PPM_API_BASE_URL`).
|
||||
/// Telegram: cabinet WebView + JWT channel. Yandex: OAuth code in a WebView,
|
||||
/// exchanged on the API so the client secret never leaves the server.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({
|
||||
super.key,
|
||||
this.launchTelegramLogin,
|
||||
this.launchYandexLogin,
|
||||
});
|
||||
|
||||
/// Injection point for tests and Widgetbook, where no WebView exists.
|
||||
final TelegramLoginLauncher? launchTelegramLogin;
|
||||
final YandexLoginLauncher? launchYandexLogin;
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _tokenController = TextEditingController();
|
||||
|
||||
bool _busy = false;
|
||||
late bool _tokenMode = !_webLoginAvailable;
|
||||
YandexAuthProvider? _yandex;
|
||||
String? _providersError;
|
||||
LegalAcceptance _legal = const LegalAcceptance();
|
||||
|
||||
bool get _webLoginAvailable =>
|
||||
widget.launchTelegramLogin != null || isTelegramWebLoginSupported;
|
||||
|
||||
bool get _yandexLoginAvailable =>
|
||||
widget.launchYandexLogin != null || isYandexWebLoginSupported;
|
||||
|
||||
String _cabinetUrl(SessionController session) {
|
||||
if (session.webCabinetUrl.isNotEmpty) return session.webCabinetUrl;
|
||||
return session.baseUrl;
|
||||
}
|
||||
|
||||
String _apiBase(SessionController session) {
|
||||
return resolveApiBaseUrl(
|
||||
cabinetUrl: _cabinetUrl(session),
|
||||
configuredApiBaseUrl: session.configuredApiBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProviders();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProviders() async {
|
||||
final session = context.read<SessionController>();
|
||||
final apiBase = _apiBase(session);
|
||||
if (apiBase.isEmpty) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_providersError = 'Не задан PPM_API_BASE_URL в .env';
|
||||
});
|
||||
} else {
|
||||
_providersError = 'Не задан PPM_API_BASE_URL в .env';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final providers = await fetchAuthProviders(apiBase, httpClient: session.httpClient);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = providers.yandex;
|
||||
_providersError = providers.yandex?.usable == true
|
||||
? null
|
||||
: 'API не включил Яндекс (нет YANDEX_CLIENT_ID/SECRET на сервере)';
|
||||
});
|
||||
} on ApiException catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = null;
|
||||
_providersError = error.message;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_yandex = null;
|
||||
_providersError = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureCabinetUrl(SessionController session) async {
|
||||
if (_cabinetUrl(session).isNotEmpty) return true;
|
||||
await showAppToast(
|
||||
context,
|
||||
message: 'Задайте PPM_WEB_URL в mobile/.env',
|
||||
icon: CupertinoIcons.exclamationmark_circle_fill,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s7, bottom: AppSpacing.s6),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AppIcon(
|
||||
CupertinoIcons.money_rubl_circle_fill,
|
||||
size: 48,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
SizedBox(height: AppSpacing.s3),
|
||||
AppText.largeTitle(AppBrand.name),
|
||||
SizedBox(height: AppSpacing.s1),
|
||||
AppText.subhead(
|
||||
'Бюджет от зарплаты до зарплаты. Войдите через Яндекс.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LegalConsentBlock(
|
||||
value: _legal,
|
||||
cabinetUrl: _cabinetUrl(session),
|
||||
onChanged: (next) => setState(() => _legal = next),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
if (_tokenMode) ...[
|
||||
AppTextField(
|
||||
label: 'Токен доступа',
|
||||
placeholder: 'eyJhbGciOi…',
|
||||
controller: _tokenController,
|
||||
enabled: !_busy,
|
||||
errorText: session.lastError,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppButton(
|
||||
label: 'Войти по токену',
|
||||
loading: _busy,
|
||||
onPressed: _legal.accepted ? _signInWithToken : null,
|
||||
),
|
||||
] else ...[
|
||||
if (session.lastError != null) ...[
|
||||
AppText.footnote(session.lastError!, color: AppColors.systemRed),
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.s3),
|
||||
AppButton(
|
||||
label: 'Войти через Яндекс',
|
||||
style: AppButtonStyle.gray,
|
||||
loading: _busy,
|
||||
onPressed: _legal.accepted ? _signInWithYandex : null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
const AppListSection(
|
||||
header: 'Что умеет',
|
||||
footer: 'Один кабинет на телефоне и в браузере — бюджет не разъедется.',
|
||||
children: [
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.calendar, color: AppColors.accent),
|
||||
title: 'Бюджет от зарплаты до зарплаты',
|
||||
subtitle: 'Остаток и дневной лимит на каждый день периода',
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.money_rubl, color: AppColors.accent),
|
||||
title: 'Траты в один тап',
|
||||
subtitle: 'Журнал по дням, несколько конвертов параллельно',
|
||||
showChevron: false,
|
||||
),
|
||||
AppListTile(
|
||||
leading: AppIcon(CupertinoIcons.briefcase, color: AppColors.accent),
|
||||
title: 'График выплат',
|
||||
subtitle: 'Оклад, дни зарплаты и правило выходных',
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _signInWithTelegram() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
final cabinetUrl = _cabinetUrl(session);
|
||||
final launcher = widget.launchTelegramLogin ??
|
||||
(ctx, url) => showTelegramLogin(context: ctx, cabinetUrl: url);
|
||||
|
||||
setState(() => _busy = true);
|
||||
final token = await launcher(context, cabinetUrl);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await session.signInWithToken(baseUrl: _apiBase(session), token: token);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _signInWithYandex() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
if (_yandex == null || !(_yandex?.usable ?? false)) {
|
||||
setState(() => _busy = true);
|
||||
await _loadProviders();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
final provider = _yandex;
|
||||
if (provider == null || !provider.usable) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: _providersError ?? 'Вход через Яндекс недоступен',
|
||||
icon: CupertinoIcons.exclamationmark_circle_fill,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_yandexLoginAvailable) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: 'Яндекс в приложении работает на телефоне. На Windows откройте кабинет в браузере.',
|
||||
icon: CupertinoIcons.device_phone_portrait,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final cabinetUrl = _cabinetUrl(session);
|
||||
final apiBase = _apiBase(session);
|
||||
final redirectUri = cabinetYandexRedirectUri(
|
||||
cabinetUrl,
|
||||
configured: provider.redirectUri,
|
||||
);
|
||||
|
||||
setState(() => _busy = true);
|
||||
|
||||
final launcher = widget.launchYandexLogin;
|
||||
final token = launcher != null
|
||||
? await launcher(context, clientId: provider.clientId, redirectUri: redirectUri)
|
||||
: await showYandexLogin(
|
||||
context: context,
|
||||
clientId: provider.clientId,
|
||||
redirectUri: redirectUri,
|
||||
exchangeCode: (code) => exchangeYandexCode(
|
||||
apiBaseUrl: apiBase,
|
||||
code: code,
|
||||
redirectUri: redirectUri,
|
||||
httpClient: session.httpClient,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
setState(() => _busy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await session.signInWithToken(baseUrl: apiBase, token: token);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
Future<void> _signInWithToken() async {
|
||||
final session = context.read<SessionController>();
|
||||
if (!await _ensureCabinetUrl(session)) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
await session.signInWithToken(
|
||||
baseUrl: _apiBase(session),
|
||||
token: _tokenController.text,
|
||||
);
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:please_pay_me/core/config/app_config.dart';
|
||||
import 'package:please_pay_me/core/storage/session_storage.dart';
|
||||
import 'package:please_pay_me/data/api/api_client.dart';
|
||||
import 'package:please_pay_me/data/demo/demo_backend.dart';
|
||||
import 'package:please_pay_me/data/models/auth_user.dart';
|
||||
import 'package:please_pay_me/data/repositories/api_repositories.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
enum SessionStatus { restoring, signedOut, signedIn }
|
||||
|
||||
/// Owns authentication and hands out repositories bound to the current
|
||||
/// session, so the rest of the app never sees tokens or base URLs.
|
||||
class SessionController extends ChangeNotifier {
|
||||
SessionController({
|
||||
required AppConfig config,
|
||||
required SessionStorage storage,
|
||||
http.Client? httpClient,
|
||||
DemoBackend? demoBackend,
|
||||
}) : _config = config,
|
||||
_storage = storage,
|
||||
_httpClient = httpClient,
|
||||
_demo = demoBackend ?? DemoBackend();
|
||||
|
||||
final SessionStorage _storage;
|
||||
final http.Client? _httpClient;
|
||||
final DemoBackend _demo;
|
||||
|
||||
AppConfig _config;
|
||||
SessionStatus _status = SessionStatus.restoring;
|
||||
AuthUser? _user;
|
||||
String? _token;
|
||||
String _baseUrl = '';
|
||||
bool _isDemo = false;
|
||||
String? _lastError;
|
||||
|
||||
SessionStatus get status => _status;
|
||||
AuthUser? get user => _user;
|
||||
String get baseUrl => _baseUrl.isEmpty ? _config.apiBaseUrl : _baseUrl;
|
||||
String get webCabinetUrl => _config.webCabinetUrl;
|
||||
|
||||
/// API address baked in at build time; empty when it must be derived from
|
||||
/// the cabinet URL the user typed.
|
||||
String get configuredApiBaseUrl => _config.apiBaseUrl;
|
||||
bool get isDemo => _isDemo;
|
||||
String? get lastError => _lastError;
|
||||
http.Client? get httpClient => _httpClient;
|
||||
|
||||
/// Changes whenever the backing data source changes, so feature controllers
|
||||
/// can be rebuilt from scratch on login / logout.
|
||||
String get sessionKey => '${_isDemo ? 'demo' : baseUrl}:${_user?.userId ?? 0}';
|
||||
|
||||
BudgetRepository get budgets => _repositories.budgets;
|
||||
ExpenseRepository get expenses => _repositories.expenses;
|
||||
JobRepository get jobs => _repositories.jobs;
|
||||
UserRepository get users => _repositories.users;
|
||||
|
||||
_Repositories get _repositories {
|
||||
if (_isDemo || _token == null) return _demoRepositories;
|
||||
return _apiRepositories ??= _buildApiRepositories();
|
||||
}
|
||||
|
||||
_Repositories? _apiRepositories;
|
||||
|
||||
late final _Repositories _demoRepositories = _Repositories(
|
||||
budgets: _demo.budgets,
|
||||
expenses: _demo.expenses,
|
||||
jobs: _demo.jobs,
|
||||
users: _demo.users,
|
||||
);
|
||||
|
||||
_Repositories _buildApiRepositories() {
|
||||
final client = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
tokenProvider: () => _token,
|
||||
httpClient: _httpClient,
|
||||
onUnauthorized: signOut,
|
||||
);
|
||||
return _Repositories(
|
||||
budgets: ApiBudgetRepository(client),
|
||||
expenses: ApiExpenseRepository(client),
|
||||
jobs: ApiJobRepository(client),
|
||||
users: ApiUserRepository(client),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> restore() async {
|
||||
final stored = await _storage.readAll();
|
||||
final token = stored['token'];
|
||||
final baseUrl = stored['baseUrl'];
|
||||
|
||||
if (token != null && token.isNotEmpty && baseUrl != null && baseUrl.isNotEmpty) {
|
||||
_token = token;
|
||||
_baseUrl = baseUrl;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
_user = AuthUser.tryDecode(stored['user']);
|
||||
_status = SessionStatus.signedIn;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_config.demoMode && _config.apiBaseUrl.isEmpty) {
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Signs in with a JWT issued by Telegram or Yandex (`POST /api/auth/*`).
|
||||
Future<bool> signInWithToken({required String baseUrl, required String token}) async {
|
||||
final normalizedUrl = baseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
final normalizedToken = token.trim();
|
||||
|
||||
if (normalizedUrl.isEmpty || normalizedToken.isEmpty) {
|
||||
_lastError = 'Укажите адрес кабинета и токен';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_lastError = null;
|
||||
_baseUrl = normalizedUrl;
|
||||
_token = normalizedToken;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
|
||||
try {
|
||||
final user = await _repositories.users.me();
|
||||
_user = user;
|
||||
_status = SessionStatus.signedIn;
|
||||
_config = _config.copyWith(apiBaseUrl: normalizedUrl, demoMode: false);
|
||||
await _storage.write(
|
||||
token: normalizedToken,
|
||||
baseUrl: normalizedUrl,
|
||||
user: user.encode(),
|
||||
);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} on ApiException catch (error) {
|
||||
_token = null;
|
||||
_apiRepositories = null;
|
||||
_lastError = error.message;
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the app against [DemoBackend] — no server required.
|
||||
void startDemo() {
|
||||
_isDemo = true;
|
||||
_token = null;
|
||||
_user = DemoBackend.user;
|
||||
_lastError = null;
|
||||
_status = SessionStatus.signedIn;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
await _storage.clear();
|
||||
_token = null;
|
||||
_user = null;
|
||||
_isDemo = false;
|
||||
_apiRepositories = null;
|
||||
_status = SessionStatus.signedOut;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class _Repositories {
|
||||
const _Repositories({
|
||||
required this.budgets,
|
||||
required this.expenses,
|
||||
required this.jobs,
|
||||
required this.users,
|
||||
});
|
||||
|
||||
final BudgetRepository budgets;
|
||||
final ExpenseRepository expenses;
|
||||
final JobRepository jobs;
|
||||
final UserRepository users;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
|
||||
/// Name of the JavaScript channel injected into the cabinet page.
|
||||
/// Must stay in sync with `web/src/auth/telegramRedirect.ts`.
|
||||
const telegramAuthChannel = 'PpmAuth';
|
||||
|
||||
/// Chrome-like UA without the `; wv` token Android WebView inserts.
|
||||
/// Telegram's widget rejects the default WebView user-agent on some devices.
|
||||
const telegramWebViewUserAgent =
|
||||
'Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
/// Runs inside the cabinet and hands the JWT back to Flutter.
|
||||
///
|
||||
/// Two paths, both without backend changes:
|
||||
/// 1. The web app posts to `PpmAuth` from `setSession` right after login.
|
||||
/// 2. This script hooks `localStorage.setItem` and polls, so an already-open
|
||||
/// session (or an older cabinet build) still works.
|
||||
const telegramTokenProbeJs = '''
|
||||
(function () {
|
||||
function ping() {
|
||||
try {
|
||||
var token = window.localStorage.getItem('ppm_session_jwt');
|
||||
if (token && window.$telegramAuthChannel) {
|
||||
$telegramAuthChannel.postMessage(JSON.stringify({ token: token }));
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (!window.__ppmAuthHooked) {
|
||||
window.__ppmAuthHooked = true;
|
||||
try {
|
||||
var original = window.localStorage.setItem.bind(window.localStorage);
|
||||
window.localStorage.setItem = function (key, value) {
|
||||
original(key, value);
|
||||
if (key === 'ppm_session_jwt') ping();
|
||||
};
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
ping();
|
||||
})();
|
||||
''';
|
||||
|
||||
/// WebView login only exists on mobile; desktop and web fall back to the token
|
||||
/// form.
|
||||
bool get isTelegramWebLoginSupported {
|
||||
if (kIsWeb) return false;
|
||||
return defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
}
|
||||
|
||||
/// The cabinet and the API share an origin (nginx proxies `/api`), so the API
|
||||
/// address can be derived unless it was configured explicitly.
|
||||
String resolveApiBaseUrl({
|
||||
required String cabinetUrl,
|
||||
String configuredApiBaseUrl = '',
|
||||
}) {
|
||||
if (configuredApiBaseUrl.trim().isNotEmpty) {
|
||||
return configuredApiBaseUrl.trim().replaceAll(RegExp(r'/$'), '');
|
||||
}
|
||||
|
||||
return resolveCabinetOrigin(cabinetUrl);
|
||||
}
|
||||
|
||||
/// Origin of the cabinet URL — used both as the API base and as a sanity check.
|
||||
String resolveCabinetOrigin(String cabinetUrl) {
|
||||
final uri = resolveCabinetLoginUri(cabinetUrl);
|
||||
return uri.hasPort && !_isDefaultPort(uri)
|
||||
? '${uri.scheme}://${uri.host}:${uri.port}'
|
||||
: '${uri.scheme}://${uri.host}';
|
||||
}
|
||||
|
||||
/// Always open `/login` so the Telegram widget is on screen.
|
||||
Uri resolveCabinetLoginUri(String cabinetUrl) {
|
||||
var raw = cabinetUrl.trim();
|
||||
if (raw.isEmpty) return Uri.parse('https://localhost/login');
|
||||
|
||||
if (!raw.contains('://')) {
|
||||
raw = 'https://$raw';
|
||||
}
|
||||
|
||||
final uri = Uri.parse(raw);
|
||||
final path = uri.path;
|
||||
if (path.isEmpty || path == '/') {
|
||||
return uri.replace(path: '/login');
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
bool _isDefaultPort(Uri uri) {
|
||||
return (uri.scheme == 'https' && uri.port == 443) ||
|
||||
(uri.scheme == 'http' && uri.port == 80);
|
||||
}
|
||||
|
||||
/// Extracts the JWT from the payload posted by [telegramTokenProbeJs].
|
||||
String? parseTelegramAuthMessage(String raw) {
|
||||
try {
|
||||
final token = asString(asMap(jsonDecode(raw))['token']);
|
||||
return token.isEmpty ? null : token;
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool isExternalAuthScheme(Uri uri) {
|
||||
return uri.scheme == 'tg' || uri.scheme == 'telegram';
|
||||
}
|
||||
|
||||
bool isTelegramOAuthHost(String host) {
|
||||
return host == 'oauth.telegram.org' ||
|
||||
host == 'telegram.org' ||
|
||||
host.endsWith('.telegram.org');
|
||||
}
|
||||
|
||||
/// Opens the cabinet in an in-app browser and resolves with the JWT once the
|
||||
/// user has signed in through the Telegram Login Widget.
|
||||
Future<String?> showTelegramLogin({
|
||||
required BuildContext context,
|
||||
required String cabinetUrl,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push<String>(
|
||||
CupertinoPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => TelegramLoginScreen(cabinetUrl: cabinetUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class TelegramLoginScreen extends StatefulWidget {
|
||||
const TelegramLoginScreen({super.key, required this.cabinetUrl});
|
||||
|
||||
final String cabinetUrl;
|
||||
|
||||
@override
|
||||
State<TelegramLoginScreen> createState() => _TelegramLoginScreenState();
|
||||
}
|
||||
|
||||
class _TelegramLoginScreenState extends State<TelegramLoginScreen> {
|
||||
late final WebViewController _controller;
|
||||
Timer? _poll;
|
||||
bool _loading = true;
|
||||
bool _completed = false;
|
||||
bool _canGoBack = false;
|
||||
String? _error;
|
||||
|
||||
Uri get _startUri => resolveCabinetLoginUri(widget.cabinetUrl);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setUserAgent(telegramWebViewUserAgent)
|
||||
..setBackgroundColor(const Color(0x00000000))
|
||||
..addJavaScriptChannel(
|
||||
telegramAuthChannel,
|
||||
onMessageReceived: (message) => _onToken(message.message),
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: _onNavigationRequest,
|
||||
onPageStarted: (_) {
|
||||
if (mounted) setState(() => _loading = true);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
_refreshCanGoBack();
|
||||
if (mounted) setState(() => _loading = false);
|
||||
_probe();
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted || _completed) return;
|
||||
// Subframe errors (the Telegram iframe) must not kill the page.
|
||||
if (error.isForMainFrame == false) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = error.description;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_configureAndroid();
|
||||
_controller.loadRequest(_startUri);
|
||||
|
||||
// The widget writes the token after an async callback, so polling is more
|
||||
// reliable than a single probe on page load.
|
||||
_poll = Timer.periodic(const Duration(milliseconds: 700), (_) => _probe());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_poll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _probe() async {
|
||||
if (_completed) return;
|
||||
try {
|
||||
await _controller.runJavaScript(telegramTokenProbeJs);
|
||||
} on PlatformException {
|
||||
// The page may be mid-navigation; the next tick retries.
|
||||
}
|
||||
}
|
||||
|
||||
Future<NavigationDecision> _onNavigationRequest(NavigationRequest request) async {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null) return NavigationDecision.navigate;
|
||||
|
||||
if (isExternalAuthScheme(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
|
||||
return NavigationDecision.navigate;
|
||||
}
|
||||
|
||||
Future<void> _refreshCanGoBack() async {
|
||||
final canGoBack = await _controller.canGoBack();
|
||||
if (mounted && canGoBack != _canGoBack) {
|
||||
setState(() => _canGoBack = canGoBack);
|
||||
}
|
||||
}
|
||||
|
||||
void _onToken(String raw) {
|
||||
if (_completed) return;
|
||||
|
||||
final token = parseTelegramAuthMessage(raw);
|
||||
if (token == null) return;
|
||||
|
||||
_completed = true;
|
||||
_poll?.cancel();
|
||||
Navigator.of(context).pop(token);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: 'Вход через Telegram',
|
||||
subtitle: _startUri.host,
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const AppText.body('Закрыть', color: AppColors.accent),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_canGoBack)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () async {
|
||||
await _controller.goBack();
|
||||
await _refreshCanGoBack();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.back, color: AppColors.accent),
|
||||
),
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: Size.zero,
|
||||
onPressed: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
child: const AppIcon(CupertinoIcons.refresh, color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: _error != null
|
||||
? AppErrorView(
|
||||
message: _error!,
|
||||
onRetry: () {
|
||||
setState(() => _error = null);
|
||||
_controller.reload();
|
||||
},
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_loading) const Center(child: AppSpinner()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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