Use Telegram Login Widget for web auth
Some checks failed
Build and deploy Flutter Web / build (push) Has been cancelled
Some checks failed
Build and deploy Flutter Web / build (push) Has been cancelled
This commit is contained in:
@@ -43,7 +43,7 @@ jobs:
|
||||
--tag "$IMAGE" \
|
||||
--build-arg MAPBOX_ACCESS_TOKEN="${{ secrets.MAPBOX_ACCESS_TOKEN }}" \
|
||||
--build-arg MAPBOX_STYLE="mapbox/streets-v12" \
|
||||
--build-arg TELEGRAM_BOT_URL="https://t.me/carfteebot" \
|
||||
--build-arg TELEGRAM_BOT_USERNAME="carfteebot" \
|
||||
.
|
||||
|
||||
- name: Skip stale deployment
|
||||
|
||||
@@ -2,14 +2,14 @@ FROM ghcr.io/cirruslabs/flutter:stable AS build
|
||||
WORKDIR /app
|
||||
ARG MAPBOX_ACCESS_TOKEN=""
|
||||
ARG MAPBOX_STYLE="mapbox/streets-v12"
|
||||
ARG TELEGRAM_BOT_URL="https://t.me/carfteebot"
|
||||
ARG TELEGRAM_BOT_USERNAME="carfteebot"
|
||||
COPY pubspec.* ./
|
||||
RUN flutter pub get
|
||||
COPY . .
|
||||
RUN flutter build web --release \
|
||||
--dart-define=MAPBOX_ACCESS_TOKEN="$MAPBOX_ACCESS_TOKEN" \
|
||||
--dart-define=MAPBOX_STYLE="$MAPBOX_STYLE" \
|
||||
--dart-define=TELEGRAM_BOT_URL="$TELEGRAM_BOT_URL"
|
||||
--dart-define=TELEGRAM_BOT_USERNAME="$TELEGRAM_BOT_USERNAME"
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -3,32 +3,65 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../auth/telegram_init_data.dart' as telegram_auth;
|
||||
import '../auth/telegram_session.dart' as telegram_auth;
|
||||
import '../models/place_models.dart';
|
||||
|
||||
class MapflowApi {
|
||||
MapflowApi({
|
||||
http.Client? client,
|
||||
String? telegramInitData,
|
||||
String? telegramLoginData,
|
||||
String endpoint = const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: '/graphql',
|
||||
),
|
||||
}) : _client = client ?? http.Client(),
|
||||
_telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
|
||||
_telegramLoginData =
|
||||
telegramLoginData ?? telegram_auth.telegramLoginData(),
|
||||
_endpoint = Uri.base.resolve(endpoint);
|
||||
|
||||
final http.Client _client;
|
||||
final String _telegramInitData;
|
||||
final String _telegramLoginData;
|
||||
final Uri _endpoint;
|
||||
|
||||
bool get hasTelegramAuth => _telegramInitData.isNotEmpty;
|
||||
bool get hasTelegramAuth =>
|
||||
_telegramInitData.isNotEmpty || _telegramLoginData.isNotEmpty;
|
||||
|
||||
Future<AppUser?> authenticateTelegram() async {
|
||||
if (_telegramInitData.isEmpty) {
|
||||
if (!hasTelegramAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_telegramLoginData.isNotEmpty) {
|
||||
final loginData = jsonDecode(_telegramLoginData) as Map<String, dynamic>;
|
||||
final data = await _graphql(
|
||||
'''
|
||||
mutation AuthenticateTelegramLogin(
|
||||
\$input: AuthenticateTelegramLoginInput!
|
||||
) {
|
||||
authenticateTelegramLogin(input: \$input) {
|
||||
user {
|
||||
id
|
||||
telegramId
|
||||
username
|
||||
firstName
|
||||
lastName
|
||||
photoUrl
|
||||
languageCode
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
variables: {'input': loginData},
|
||||
);
|
||||
|
||||
final payload = data['authenticateTelegramLogin'] as Map<String, dynamic>;
|
||||
final user = payload['user'] as Map<String, dynamic>;
|
||||
return AppUser.fromJson(user);
|
||||
}
|
||||
|
||||
final data = await _graphql(
|
||||
'''
|
||||
mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) {
|
||||
@@ -98,7 +131,7 @@ class MapflowApi {
|
||||
required int durationSeconds,
|
||||
required String audioObjectKey,
|
||||
}) async {
|
||||
if (_telegramInitData.isEmpty) {
|
||||
if (!hasTelegramAuth) {
|
||||
throw StateError('Telegram authorization is required.');
|
||||
}
|
||||
|
||||
@@ -133,6 +166,8 @@ class MapflowApi {
|
||||
'content-type': 'application/json',
|
||||
if (_telegramInitData.isNotEmpty)
|
||||
'x-telegram-init-data': _telegramInitData,
|
||||
if (_telegramLoginData.isNotEmpty)
|
||||
'x-telegram-login-data': _telegramLoginData,
|
||||
},
|
||||
body: jsonEncode({'query': query, 'variables': variables ?? {}}),
|
||||
);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'telegram_init_data_stub.dart'
|
||||
if (dart.library.js_interop) 'telegram_init_data_web.dart';
|
||||
@@ -1 +0,0 @@
|
||||
String telegramInitData() => '';
|
||||
@@ -1,6 +0,0 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('window.Telegram.WebApp.initData')
|
||||
external JSString? get _telegramInitData;
|
||||
|
||||
String telegramInitData() => _telegramInitData?.toDart ?? '';
|
||||
@@ -1,2 +0,0 @@
|
||||
export 'telegram_launcher_stub.dart'
|
||||
if (dart.library.js_interop) 'telegram_launcher_web.dart';
|
||||
@@ -1 +0,0 @@
|
||||
void openTelegramUrl(String url) {}
|
||||
@@ -1,8 +0,0 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('window.open')
|
||||
external JSAny? _open(JSString url, JSString target, JSString features);
|
||||
|
||||
void openTelegramUrl(String url) {
|
||||
_open(url.toJS, '_blank'.toJS, 'noopener,noreferrer'.toJS);
|
||||
}
|
||||
2
lib/auth/telegram_login_button.dart
Normal file
2
lib/auth/telegram_login_button.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'telegram_login_button_stub.dart'
|
||||
if (dart.library.html) 'telegram_login_button_web.dart';
|
||||
12
lib/auth/telegram_login_button_stub.dart
Normal file
12
lib/auth/telegram_login_button_stub.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TelegramLoginButton extends StatelessWidget {
|
||||
const TelegramLoginButton({required this.onAuthenticated, super.key});
|
||||
|
||||
final VoidCallback onAuthenticated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const FilledButton(onPressed: null, child: Text('Telegram'));
|
||||
}
|
||||
}
|
||||
76
lib/auth/telegram_login_button_web.dart
Normal file
76
lib/auth/telegram_login_button_web.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import 'telegram_session_web.dart';
|
||||
|
||||
const _telegramBotUsername = String.fromEnvironment(
|
||||
'TELEGRAM_BOT_USERNAME',
|
||||
defaultValue: 'carfteebot',
|
||||
);
|
||||
|
||||
@JS('window')
|
||||
external JSObject get _window;
|
||||
|
||||
class TelegramLoginButton extends StatefulWidget {
|
||||
const TelegramLoginButton({required this.onAuthenticated, super.key});
|
||||
|
||||
final VoidCallback onAuthenticated;
|
||||
|
||||
@override
|
||||
State<TelegramLoginButton> createState() => _TelegramLoginButtonState();
|
||||
}
|
||||
|
||||
class _TelegramLoginButtonState extends State<TelegramLoginButton> {
|
||||
late final String _callbackName =
|
||||
'mapflowTelegramAuth${DateTime.now().microsecondsSinceEpoch}';
|
||||
late final String _viewType = 'mapflow-telegram-login-$_callbackName';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final callback = ((JSAny? user) {
|
||||
saveTelegramLoginData(user);
|
||||
if (mounted) {
|
||||
widget.onAuthenticated();
|
||||
}
|
||||
}).toJS;
|
||||
_window.setProperty(_callbackName.toJS, callback);
|
||||
ui_web.platformViewRegistry.registerViewFactory(_viewType, _buildElement);
|
||||
}
|
||||
|
||||
web.HTMLElement _buildElement(int viewId) {
|
||||
final container = web.HTMLDivElement()
|
||||
..style.width = '100%'
|
||||
..style.height = '48px'
|
||||
..style.display = 'flex'
|
||||
..style.justifyContent = 'center'
|
||||
..style.alignItems = 'center';
|
||||
|
||||
final script = web.HTMLScriptElement()
|
||||
..src = 'https://telegram.org/js/telegram-widget.js?22'
|
||||
..async = true;
|
||||
|
||||
script
|
||||
..setAttribute('data-telegram-login', _telegramBotUsername)
|
||||
..setAttribute('data-size', 'large')
|
||||
..setAttribute('data-radius', '8')
|
||||
..setAttribute('data-userpic', 'false')
|
||||
..setAttribute('data-onauth', '$_callbackName(user)');
|
||||
|
||||
container.append(script);
|
||||
return container;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
width: 240,
|
||||
child: HtmlElementView(viewType: _viewType),
|
||||
);
|
||||
}
|
||||
}
|
||||
2
lib/auth/telegram_session.dart
Normal file
2
lib/auth/telegram_session.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'telegram_session_stub.dart'
|
||||
if (dart.library.html) 'telegram_session_web.dart';
|
||||
3
lib/auth/telegram_session_stub.dart
Normal file
3
lib/auth/telegram_session_stub.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
String telegramInitData() => '';
|
||||
|
||||
String telegramLoginData() => '';
|
||||
21
lib/auth/telegram_session_web.dart
Normal file
21
lib/auth/telegram_session_web.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'dart:js_interop';
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
const telegramLoginStorageKey = 'mapflow.telegramLoginData';
|
||||
|
||||
@JS('window.Telegram.WebApp.initData')
|
||||
external JSString? get _telegramInitData;
|
||||
|
||||
@JS('JSON.stringify')
|
||||
external JSString _jsonStringify(JSAny? value);
|
||||
|
||||
String telegramInitData() => _telegramInitData?.toDart ?? '';
|
||||
|
||||
String telegramLoginData() =>
|
||||
web.window.localStorage.getItem(telegramLoginStorageKey) ?? '';
|
||||
|
||||
void saveTelegramLoginData(JSAny? user) {
|
||||
final encoded = _jsonStringify(user).toDart;
|
||||
web.window.localStorage.setItem(telegramLoginStorageKey, encoded);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../auth/telegram_launcher.dart';
|
||||
import '../auth/telegram_login_button.dart';
|
||||
import '../models/place_models.dart';
|
||||
import '../state/place_controller.dart';
|
||||
|
||||
@@ -14,10 +14,6 @@ const _mapboxStyle = String.fromEnvironment(
|
||||
'MAPBOX_STYLE',
|
||||
defaultValue: 'mapbox/streets-v12',
|
||||
);
|
||||
const _telegramBotUrl = String.fromEnvironment(
|
||||
'TELEGRAM_BOT_URL',
|
||||
defaultValue: 'https://t.me/carfteebot',
|
||||
);
|
||||
|
||||
class MapflowShell extends ConsumerWidget {
|
||||
const MapflowShell({super.key});
|
||||
@@ -29,9 +25,8 @@ class MapflowShell extends ConsumerWidget {
|
||||
return asyncState.when(
|
||||
data: (state) => state.hasTelegramAuth
|
||||
? _MapContent(state: state)
|
||||
: _TelegramAuthGate(
|
||||
onOpenTelegram: () => openTelegramUrl(_telegramBotUrl),
|
||||
onRetry: () => ref.invalidate(placeControllerProvider),
|
||||
: _TelegramLoginScreen(
|
||||
onAuthenticated: () => ref.invalidate(placeControllerProvider),
|
||||
),
|
||||
loading: () => const _MapLoading(),
|
||||
error: (error, _) => _MapError(message: error.toString()),
|
||||
@@ -129,66 +124,34 @@ class _MapContent extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _TelegramAuthGate extends StatelessWidget {
|
||||
const _TelegramAuthGate({
|
||||
required this.onOpenTelegram,
|
||||
required this.onRetry,
|
||||
});
|
||||
class _TelegramLoginScreen extends StatelessWidget {
|
||||
const _TelegramLoginScreen({required this.onAuthenticated});
|
||||
|
||||
final VoidCallback onOpenTelegram;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onAuthenticated;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
options: const MapOptions(
|
||||
initialCenter: LatLng(10.7718, 106.6982),
|
||||
initialZoom: 14.2,
|
||||
),
|
||||
children: [const _BaseMapTileLayer(), const _MapAttribution()],
|
||||
),
|
||||
ColoredBox(color: Colors.black.withValues(alpha: 0.18)),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBF5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'MapFlow',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Открой через Telegram, чтобы продолжить.',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: onOpenTelegram,
|
||||
icon: const Icon(Icons.telegram),
|
||||
label: const Text('Telegram'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Обновить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TelegramLoginButton(onAuthenticated: onAuthenticated),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies:
|
||||
flutter_map: ^8.3.0
|
||||
latlong2: ^0.9.1
|
||||
http: ^1.6.0
|
||||
web: ^1.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:mapflow/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders the mapflow shell', (tester) async {
|
||||
testWidgets('renders Telegram login before authorization', (tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: MapflowApp()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(FlutterMap), findsOneWidget);
|
||||
expect(find.text('MapFlow'), findsOneWidget);
|
||||
expect(find.text('Telegram'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user