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" \
|
--tag "$IMAGE" \
|
||||||
--build-arg MAPBOX_ACCESS_TOKEN="${{ secrets.MAPBOX_ACCESS_TOKEN }}" \
|
--build-arg MAPBOX_ACCESS_TOKEN="${{ secrets.MAPBOX_ACCESS_TOKEN }}" \
|
||||||
--build-arg MAPBOX_STYLE="mapbox/streets-v12" \
|
--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
|
- name: Skip stale deployment
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ FROM ghcr.io/cirruslabs/flutter:stable AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG MAPBOX_ACCESS_TOKEN=""
|
ARG MAPBOX_ACCESS_TOKEN=""
|
||||||
ARG MAPBOX_STYLE="mapbox/streets-v12"
|
ARG MAPBOX_STYLE="mapbox/streets-v12"
|
||||||
ARG TELEGRAM_BOT_URL="https://t.me/carfteebot"
|
ARG TELEGRAM_BOT_USERNAME="carfteebot"
|
||||||
COPY pubspec.* ./
|
COPY pubspec.* ./
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN flutter build web --release \
|
RUN flutter build web --release \
|
||||||
--dart-define=MAPBOX_ACCESS_TOKEN="$MAPBOX_ACCESS_TOKEN" \
|
--dart-define=MAPBOX_ACCESS_TOKEN="$MAPBOX_ACCESS_TOKEN" \
|
||||||
--dart-define=MAPBOX_STYLE="$MAPBOX_STYLE" \
|
--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
|
FROM nginx:1.27-alpine
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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:http/http.dart' as http;
|
||||||
import 'package:latlong2/latlong.dart';
|
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';
|
import '../models/place_models.dart';
|
||||||
|
|
||||||
class MapflowApi {
|
class MapflowApi {
|
||||||
MapflowApi({
|
MapflowApi({
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
String? telegramInitData,
|
String? telegramInitData,
|
||||||
|
String? telegramLoginData,
|
||||||
String endpoint = const String.fromEnvironment(
|
String endpoint = const String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: '/graphql',
|
defaultValue: '/graphql',
|
||||||
),
|
),
|
||||||
}) : _client = client ?? http.Client(),
|
}) : _client = client ?? http.Client(),
|
||||||
_telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
|
_telegramInitData = telegramInitData ?? telegram_auth.telegramInitData(),
|
||||||
|
_telegramLoginData =
|
||||||
|
telegramLoginData ?? telegram_auth.telegramLoginData(),
|
||||||
_endpoint = Uri.base.resolve(endpoint);
|
_endpoint = Uri.base.resolve(endpoint);
|
||||||
|
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String _telegramInitData;
|
final String _telegramInitData;
|
||||||
|
final String _telegramLoginData;
|
||||||
final Uri _endpoint;
|
final Uri _endpoint;
|
||||||
|
|
||||||
bool get hasTelegramAuth => _telegramInitData.isNotEmpty;
|
bool get hasTelegramAuth =>
|
||||||
|
_telegramInitData.isNotEmpty || _telegramLoginData.isNotEmpty;
|
||||||
|
|
||||||
Future<AppUser?> authenticateTelegram() async {
|
Future<AppUser?> authenticateTelegram() async {
|
||||||
if (_telegramInitData.isEmpty) {
|
if (!hasTelegramAuth) {
|
||||||
return null;
|
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(
|
final data = await _graphql(
|
||||||
'''
|
'''
|
||||||
mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) {
|
mutation AuthenticateTelegram(\$input: AuthenticateTelegramInput!) {
|
||||||
@@ -98,7 +131,7 @@ class MapflowApi {
|
|||||||
required int durationSeconds,
|
required int durationSeconds,
|
||||||
required String audioObjectKey,
|
required String audioObjectKey,
|
||||||
}) async {
|
}) async {
|
||||||
if (_telegramInitData.isEmpty) {
|
if (!hasTelegramAuth) {
|
||||||
throw StateError('Telegram authorization is required.');
|
throw StateError('Telegram authorization is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +166,8 @@ class MapflowApi {
|
|||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
if (_telegramInitData.isNotEmpty)
|
if (_telegramInitData.isNotEmpty)
|
||||||
'x-telegram-init-data': _telegramInitData,
|
'x-telegram-init-data': _telegramInitData,
|
||||||
|
if (_telegramLoginData.isNotEmpty)
|
||||||
|
'x-telegram-login-data': _telegramLoginData,
|
||||||
},
|
},
|
||||||
body: jsonEncode({'query': query, 'variables': variables ?? {}}),
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
import '../auth/telegram_launcher.dart';
|
import '../auth/telegram_login_button.dart';
|
||||||
import '../models/place_models.dart';
|
import '../models/place_models.dart';
|
||||||
import '../state/place_controller.dart';
|
import '../state/place_controller.dart';
|
||||||
|
|
||||||
@@ -14,10 +14,6 @@ const _mapboxStyle = String.fromEnvironment(
|
|||||||
'MAPBOX_STYLE',
|
'MAPBOX_STYLE',
|
||||||
defaultValue: 'mapbox/streets-v12',
|
defaultValue: 'mapbox/streets-v12',
|
||||||
);
|
);
|
||||||
const _telegramBotUrl = String.fromEnvironment(
|
|
||||||
'TELEGRAM_BOT_URL',
|
|
||||||
defaultValue: 'https://t.me/carfteebot',
|
|
||||||
);
|
|
||||||
|
|
||||||
class MapflowShell extends ConsumerWidget {
|
class MapflowShell extends ConsumerWidget {
|
||||||
const MapflowShell({super.key});
|
const MapflowShell({super.key});
|
||||||
@@ -29,9 +25,8 @@ class MapflowShell extends ConsumerWidget {
|
|||||||
return asyncState.when(
|
return asyncState.when(
|
||||||
data: (state) => state.hasTelegramAuth
|
data: (state) => state.hasTelegramAuth
|
||||||
? _MapContent(state: state)
|
? _MapContent(state: state)
|
||||||
: _TelegramAuthGate(
|
: _TelegramLoginScreen(
|
||||||
onOpenTelegram: () => openTelegramUrl(_telegramBotUrl),
|
onAuthenticated: () => ref.invalidate(placeControllerProvider),
|
||||||
onRetry: () => ref.invalidate(placeControllerProvider),
|
|
||||||
),
|
),
|
||||||
loading: () => const _MapLoading(),
|
loading: () => const _MapLoading(),
|
||||||
error: (error, _) => _MapError(message: error.toString()),
|
error: (error, _) => _MapError(message: error.toString()),
|
||||||
@@ -129,67 +124,35 @@ class _MapContent extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TelegramAuthGate extends StatelessWidget {
|
class _TelegramLoginScreen extends StatelessWidget {
|
||||||
const _TelegramAuthGate({
|
const _TelegramLoginScreen({required this.onAuthenticated});
|
||||||
required this.onOpenTelegram,
|
|
||||||
required this.onRetry,
|
|
||||||
});
|
|
||||||
|
|
||||||
final VoidCallback onOpenTelegram;
|
final VoidCallback onAuthenticated;
|
||||||
final VoidCallback onRetry;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: SafeArea(
|
||||||
children: [
|
child: Center(
|
||||||
FlutterMap(
|
child: SizedBox(
|
||||||
options: const MapOptions(
|
width: 320,
|
||||||
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),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Открой через Telegram, чтобы продолжить.',
|
'MapFlow',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
TelegramLoginButton(onAuthenticated: onAuthenticated),
|
||||||
onPressed: onOpenTelegram,
|
|
||||||
icon: const Icon(Icons.telegram),
|
|
||||||
label: const Text('Telegram'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: onRetry,
|
|
||||||
child: const Text('Обновить'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ dependencies:
|
|||||||
flutter_map: ^8.3.0
|
flutter_map: ^8.3.0
|
||||||
latlong2: ^0.9.1
|
latlong2: ^0.9.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
web: ^1.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:mapflow/main.dart';
|
import 'package:mapflow/main.dart';
|
||||||
|
|
||||||
void main() {
|
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.pumpWidget(const ProviderScope(child: MapflowApp()));
|
||||||
await tester.pump();
|
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