Use Telegram Login Widget for web auth
Some checks failed
Build and deploy Flutter Web / build (push) Has been cancelled

This commit is contained in:
Ruslan Bakiev
2026-05-08 18:27:03 +07:00
parent bccda6e9b6
commit be5c61a434
19 changed files with 186 additions and 91 deletions

View File

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

View File

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

View File

@@ -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 ?? {}}),
);

View File

@@ -1,2 +0,0 @@
export 'telegram_init_data_stub.dart'
if (dart.library.js_interop) 'telegram_init_data_web.dart';

View File

@@ -1 +0,0 @@
String telegramInitData() => '';

View File

@@ -1,6 +0,0 @@
import 'dart:js_interop';
@JS('window.Telegram.WebApp.initData')
external JSString? get _telegramInitData;
String telegramInitData() => _telegramInitData?.toDart ?? '';

View File

@@ -1,2 +0,0 @@
export 'telegram_launcher_stub.dart'
if (dart.library.js_interop) 'telegram_launcher_web.dart';

View File

@@ -1 +0,0 @@
void openTelegramUrl(String url) {}

View File

@@ -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);
}

View File

@@ -0,0 +1,2 @@
export 'telegram_login_button_stub.dart'
if (dart.library.html) 'telegram_login_button_web.dart';

View 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'));
}
}

View 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),
);
}
}

View File

@@ -0,0 +1,2 @@
export 'telegram_session_stub.dart'
if (dart.library.html) 'telegram_session_web.dart';

View File

@@ -0,0 +1,3 @@
String telegramInitData() => '';
String telegramLoginData() => '';

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

View File

@@ -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,67 +124,35 @@ 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,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Открой через Telegram, чтобы продолжить.',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
'MapFlow',
style: Theme.of(context).textTheme.headlineMedium?.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),
],
),
),
),
),
],
),
);
}
}

View File

@@ -713,7 +713,7 @@ packages:
source: hosted
version: "1.2.1"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"

View File

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

View File

@@ -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);
});
}