🎨 Use feature based folder structure
This commit is contained in:
110
lib/auth/auth_models/auth.dart
Normal file
110
lib/auth/auth_models/auth.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'auth.freezed.dart';
|
||||
part 'auth.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class AppToken with _$AppToken {
|
||||
const factory AppToken({required String token}) = _AppToken;
|
||||
|
||||
factory AppToken.fromJson(Map<String, dynamic> json) =>
|
||||
_$AppTokenFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class GeoIpLocation with _$GeoIpLocation {
|
||||
const factory GeoIpLocation({
|
||||
required double? latitude,
|
||||
required double? longitude,
|
||||
required String? countryCode,
|
||||
required String? country,
|
||||
required String? city,
|
||||
}) = _GeoIpLocation;
|
||||
|
||||
factory GeoIpLocation.fromJson(Map<String, dynamic> json) =>
|
||||
_$GeoIpLocationFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnAuthChallenge with _$SnAuthChallenge {
|
||||
const factory SnAuthChallenge({
|
||||
required String id,
|
||||
required DateTime? expiredAt,
|
||||
required int stepRemain,
|
||||
required int stepTotal,
|
||||
required int failedAttempts,
|
||||
required List<String> blacklistFactors,
|
||||
required List<dynamic> audiences,
|
||||
required List<dynamic> scopes,
|
||||
required String ipAddress,
|
||||
required String userAgent,
|
||||
required String? nonce,
|
||||
required GeoIpLocation? location,
|
||||
required String accountId,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
}) = _SnAuthChallenge;
|
||||
|
||||
factory SnAuthChallenge.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthChallengeFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnAuthSession with _$SnAuthSession {
|
||||
const factory SnAuthSession({
|
||||
required String id,
|
||||
required String? label,
|
||||
required DateTime lastGrantedAt,
|
||||
required DateTime? expiredAt,
|
||||
required List<dynamic> audiences,
|
||||
required List<dynamic> scopes,
|
||||
required String? ipAddress,
|
||||
required String? userAgent,
|
||||
required GeoIpLocation? location,
|
||||
required int type,
|
||||
required String accountId,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
}) = _SnAuthSession;
|
||||
|
||||
factory SnAuthSession.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthSessionFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnAuthFactor with _$SnAuthFactor {
|
||||
const factory SnAuthFactor({
|
||||
required String id,
|
||||
required int type,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required DateTime? expiredAt,
|
||||
required DateTime? enabledAt,
|
||||
required int trustworthy,
|
||||
required Map<String, dynamic>? createdResponse,
|
||||
}) = _SnAuthFactor;
|
||||
|
||||
factory SnAuthFactor.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthFactorFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SnAccountConnection with _$SnAccountConnection {
|
||||
const factory SnAccountConnection({
|
||||
required String id,
|
||||
required String accountId,
|
||||
required String provider,
|
||||
required String providedIdentifier,
|
||||
@Default({}) Map<String, dynamic> meta,
|
||||
required DateTime lastUsedAt,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
}) = _SnAccountConnection;
|
||||
|
||||
factory SnAccountConnection.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnAccountConnectionFromJson(json);
|
||||
}
|
||||
1792
lib/auth/auth_models/auth.freezed.dart
Normal file
1792
lib/auth/auth_models/auth.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
183
lib/auth/auth_models/auth.g.dart
Normal file
183
lib/auth/auth_models/auth.g.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_AppToken _$AppTokenFromJson(Map<String, dynamic> json) =>
|
||||
_AppToken(token: json['token'] as String);
|
||||
|
||||
Map<String, dynamic> _$AppTokenToJson(_AppToken instance) => <String, dynamic>{
|
||||
'token': instance.token,
|
||||
};
|
||||
|
||||
_GeoIpLocation _$GeoIpLocationFromJson(Map<String, dynamic> json) =>
|
||||
_GeoIpLocation(
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
countryCode: json['country_code'] as String?,
|
||||
country: json['country'] as String?,
|
||||
city: json['city'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) =>
|
||||
<String, dynamic>{
|
||||
'latitude': instance.latitude,
|
||||
'longitude': instance.longitude,
|
||||
'country_code': instance.countryCode,
|
||||
'country': instance.country,
|
||||
'city': instance.city,
|
||||
};
|
||||
|
||||
_SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthChallenge(
|
||||
id: json['id'] as String,
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
stepRemain: (json['step_remain'] as num).toInt(),
|
||||
stepTotal: (json['step_total'] as num).toInt(),
|
||||
failedAttempts: (json['failed_attempts'] as num).toInt(),
|
||||
blacklistFactors: (json['blacklist_factors'] as List<dynamic>)
|
||||
.map((e) => e as String)
|
||||
.toList(),
|
||||
audiences: json['audiences'] as List<dynamic>,
|
||||
scopes: json['scopes'] as List<dynamic>,
|
||||
ipAddress: json['ip_address'] as String,
|
||||
userAgent: json['user_agent'] as String,
|
||||
nonce: json['nonce'] as String?,
|
||||
location: json['location'] == null
|
||||
? null
|
||||
: GeoIpLocation.fromJson(json['location'] as Map<String, dynamic>),
|
||||
accountId: json['account_id'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'step_remain': instance.stepRemain,
|
||||
'step_total': instance.stepTotal,
|
||||
'failed_attempts': instance.failedAttempts,
|
||||
'blacklist_factors': instance.blacklistFactors,
|
||||
'audiences': instance.audiences,
|
||||
'scopes': instance.scopes,
|
||||
'ip_address': instance.ipAddress,
|
||||
'user_agent': instance.userAgent,
|
||||
'nonce': instance.nonce,
|
||||
'location': instance.location?.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthSession(
|
||||
id: json['id'] as String,
|
||||
label: json['label'] as String?,
|
||||
lastGrantedAt: DateTime.parse(json['last_granted_at'] as String),
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
audiences: json['audiences'] as List<dynamic>,
|
||||
scopes: json['scopes'] as List<dynamic>,
|
||||
ipAddress: json['ip_address'] as String?,
|
||||
userAgent: json['user_agent'] as String?,
|
||||
location: json['location'] == null
|
||||
? null
|
||||
: GeoIpLocation.fromJson(json['location'] as Map<String, dynamic>),
|
||||
type: (json['type'] as num).toInt(),
|
||||
accountId: json['account_id'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'label': instance.label,
|
||||
'last_granted_at': instance.lastGrantedAt.toIso8601String(),
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'audiences': instance.audiences,
|
||||
'scopes': instance.scopes,
|
||||
'ip_address': instance.ipAddress,
|
||||
'user_agent': instance.userAgent,
|
||||
'location': instance.location?.toJson(),
|
||||
'type': instance.type,
|
||||
'account_id': instance.accountId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthFactor(
|
||||
id: json['id'] as String,
|
||||
type: (json['type'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
expiredAt: json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
enabledAt: json['enabled_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['enabled_at'] as String),
|
||||
trustworthy: (json['trustworthy'] as num).toInt(),
|
||||
createdResponse: json['created_response'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'enabled_at': instance.enabledAt?.toIso8601String(),
|
||||
'trustworthy': instance.trustworthy,
|
||||
'created_response': instance.createdResponse,
|
||||
};
|
||||
|
||||
_SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountConnection(
|
||||
id: json['id'] as String,
|
||||
accountId: json['account_id'] as String,
|
||||
provider: json['provider'] as String,
|
||||
providedIdentifier: json['provided_identifier'] as String,
|
||||
meta: json['meta'] as Map<String, dynamic>? ?? const {},
|
||||
lastUsedAt: DateTime.parse(json['last_used_at'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAccountConnectionToJson(
|
||||
_SnAccountConnection instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'account_id': instance.accountId,
|
||||
'provider': instance.provider,
|
||||
'provided_identifier': instance.providedIdentifier,
|
||||
'meta': instance.meta,
|
||||
'last_used_at': instance.lastUsedAt.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
};
|
||||
11
lib/auth/captcha.config.dart
Normal file
11
lib/auth/captcha.config.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'captcha.config.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<String> captchaUrl(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final baseUrl = await apiClient.get('/config/site');
|
||||
return '$baseUrl/auth/captcha';
|
||||
}
|
||||
43
lib/auth/captcha.config.g.dart
Normal file
43
lib/auth/captcha.config.g.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'captcha.config.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(captchaUrl)
|
||||
final captchaUrlProvider = CaptchaUrlProvider._();
|
||||
|
||||
final class CaptchaUrlProvider
|
||||
extends $FunctionalProvider<AsyncValue<String>, String, FutureOr<String>>
|
||||
with $FutureModifier<String>, $FutureProvider<String> {
|
||||
CaptchaUrlProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'captchaUrlProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$captchaUrlHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<String> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<String> create(Ref ref) {
|
||||
return captchaUrl(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$captchaUrlHash() => r'5d59de4f26a0544bf4fbd5209943f0b111959ce6';
|
||||
1
lib/auth/captcha.dart
Normal file
1
lib/auth/captcha.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'captcha.native.dart' if (dart.library.html) 'captcha.web.dart';
|
||||
43
lib/auth/captcha.native.dart
Normal file
43
lib/auth/captcha.native.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:island/auth/captcha.config.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const CaptchaScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final captchaUrl = ref.watch(captchaUrlProvider);
|
||||
|
||||
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: "Anti-Robot",
|
||||
child: InAppWebView(
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
|
||||
),
|
||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||
Uri? url = navigationAction.request.url;
|
||||
if (url != null && url.queryParameters.containsKey('captcha_tk')) {
|
||||
Navigator.pop(context, url.queryParameters['captcha_tk']!);
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
82
lib/auth/captcha.web.dart
Normal file
82
lib/auth/captcha.web.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/auth/captcha.config.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerStatefulWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const CaptchaScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CaptchaScreen> createState() => _CaptchaScreenState();
|
||||
}
|
||||
|
||||
class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
|
||||
bool _isInitialized = false;
|
||||
|
||||
void _setupWebListener(String serverUrl) async {
|
||||
web.window.onMessage.listen((event) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
if (event.data != null && event.data is String) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("captcha_tk=")) {
|
||||
String token = message.replaceFirst("captcha_tk=", "");
|
||||
// ignore: use_build_context_synchronously
|
||||
if (context.mounted) Navigator.pop(context, token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
final captchaUrl = await ref.watch(captchaUrlProvider.future);
|
||||
|
||||
final iframe =
|
||||
web.HTMLIFrameElement()
|
||||
..src = captchaUrl
|
||||
..style.border = 'none'
|
||||
..width = '100%'
|
||||
..height = '100%';
|
||||
|
||||
web.document.body!.append(iframe);
|
||||
ui.platformViewRegistry.registerViewFactory(
|
||||
'captcha-iframe',
|
||||
(int viewId) => iframe,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(Duration.zero, () {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
_setupWebListener(serverUrl);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SheetScaffold(
|
||||
titleText: "Anti-Robot",
|
||||
child:
|
||||
_isInitialized
|
||||
? HtmlElementView(viewType: 'captcha-iframe')
|
||||
: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/auth/create_account.dart
Normal file
22
lib/auth/create_account.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
|
||||
import 'create_account_content.dart';
|
||||
|
||||
class CreateAccountScreen extends HookConsumerWidget {
|
||||
const CreateAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('createAccount').tr(),
|
||||
),
|
||||
body: CreateAccountContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
998
lib/auth/create_account_content.dart
Normal file
998
lib/auth/create_account_content.dart
Normal file
@@ -0,0 +1,998 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:email_validator/email_validator.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/core/websocket.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/core/services/notify.dart';
|
||||
import 'package:island/core/services/udid.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import 'captcha.dart';
|
||||
|
||||
const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'};
|
||||
|
||||
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
|
||||
final providerLower = provider.toLowerCase();
|
||||
|
||||
// Check if we have an SVG for this provider
|
||||
switch (providerLower) {
|
||||
case 'apple':
|
||||
case 'microsoft':
|
||||
case 'google':
|
||||
case 'github':
|
||||
case 'discord':
|
||||
case 'afdian':
|
||||
case 'steam':
|
||||
return SvgPicture.asset(
|
||||
'assets/images/oidc/$providerLower.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: color != null
|
||||
? ColorFilter.mode(color, BlendMode.srcIn)
|
||||
: null,
|
||||
);
|
||||
case 'spotify':
|
||||
return Image.asset(
|
||||
'assets/images/oidc/spotify.png',
|
||||
width: size,
|
||||
height: size,
|
||||
color: color,
|
||||
);
|
||||
default:
|
||||
return Icon(Symbols.link, size: size);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper widget for bullet list items
|
||||
class _BulletPoint extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _BulletPoint({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: Container(
|
||||
width: 6.0,
|
||||
height: 6.0,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.6).round()),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.0),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Email Entry
|
||||
class _CreateAccountEmailScreen extends HookConsumerWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController affiliationSpellController;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
final Function(String) onOidc;
|
||||
|
||||
const _CreateAccountEmailScreen({
|
||||
super.key,
|
||||
required this.emailController,
|
||||
required this.affiliationSpellController,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
required this.onOidc,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> performNext() async {
|
||||
final email = emailController.text.trim();
|
||||
if (email.isEmpty) {
|
||||
showErrorAlert('fieldCannotBeEmpty'.tr());
|
||||
return;
|
||||
}
|
||||
if (!EmailValidator.validate(email)) {
|
||||
showErrorAlert('fieldEmailAddressMustBeValid'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email availability with API
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post(
|
||||
'/pass/accounts/validate',
|
||||
data: {
|
||||
'email': email,
|
||||
if (affiliationSpellController.text.isNotEmpty)
|
||||
'affiliation_spell': affiliationSpellController.text.trim(),
|
||||
},
|
||||
);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(0),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.mail, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'createAccount',
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
controller: emailController,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'email'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNext(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
controller: affiliationSpellController,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'affiliationSpell'.tr(),
|
||||
helperText: 'affiliationSpellHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNext(),
|
||||
).padding(horizontal: 7),
|
||||
if (!kIsWeb)
|
||||
Row(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
|
||||
const Gap(8),
|
||||
Spacer(),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => onOidc('github'),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"github",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'GitHub',
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => onOidc('google'),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"google",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'Google',
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => onOidc('apple'),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"apple",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'Apple Account',
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8, top: 12)
|
||||
else
|
||||
const Gap(12),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performNext(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: Password Entry
|
||||
class _CreateAccountPasswordScreen extends HookConsumerWidget {
|
||||
final TextEditingController passwordController;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onBack;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _CreateAccountPasswordScreen({
|
||||
super.key,
|
||||
required this.passwordController,
|
||||
required this.onNext,
|
||||
required this.onBack,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
void performNext() {
|
||||
final password = passwordController.text;
|
||||
if (password.isEmpty) {
|
||||
showErrorAlert('fieldCannotBeEmpty'.tr());
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.password, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'password',
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).tr().padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'password'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNext(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => onBack(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const Icon(Symbols.chevron_left), Text('back').tr()],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performNext(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Username and Nickname Entry
|
||||
class _CreateAccountProfileScreen extends HookConsumerWidget {
|
||||
final TextEditingController usernameController;
|
||||
final TextEditingController nicknameController;
|
||||
final bool isOidcFlow;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onBack;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _CreateAccountProfileScreen({
|
||||
super.key,
|
||||
required this.usernameController,
|
||||
required this.nicknameController,
|
||||
required this.isOidcFlow,
|
||||
required this.onNext,
|
||||
required this.onBack,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> performNext() async {
|
||||
final username = usernameController.text.trim();
|
||||
final nickname = nicknameController.text.trim();
|
||||
if (username.isEmpty || nickname.isEmpty) {
|
||||
showErrorAlert('fieldCannotBeEmpty'.tr());
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate username availability with API
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post('/pass/accounts/validate', data: {'name': username});
|
||||
onNext();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(2),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.person, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'createAccountProfile'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'username'.tr(),
|
||||
helperText: 'usernameCannotChangeHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNext(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
controller: nicknameController,
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.nickname],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'nickname'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNext(),
|
||||
).padding(horizontal: 7),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => onBack(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const Icon(Symbols.chevron_left), Text('back').tr()],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performNext(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4: Terms Review
|
||||
class _CreateAccountTermsScreen extends HookConsumerWidget {
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onBack;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _CreateAccountTermsScreen({
|
||||
super.key,
|
||||
required this.onNext,
|
||||
required this.onBack,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final termsAccepted = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
void performNext() {
|
||||
if (!termsAccepted.value) {
|
||||
showErrorAlert('Please accept the terms of service to continue');
|
||||
return;
|
||||
}
|
||||
onNext();
|
||||
}
|
||||
|
||||
final unfocusColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(3),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.description, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'createAccountToS'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'createAccountNotice',
|
||||
style: TextStyle(
|
||||
color: unfocusColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
_BulletPoint(
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
style: TextStyle(color: unfocusColor),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink').tr(),
|
||||
const Gap(4),
|
||||
const Icon(Symbols.launch, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_BulletPoint(children: [Text('createAccountConfirmEmail'.tr())]),
|
||||
_BulletPoint(children: [Text('createAccountNoAltAccounts'.tr())]),
|
||||
],
|
||||
).width(double.infinity).padding(horizontal: 16, vertical: 12),
|
||||
),
|
||||
const Gap(12),
|
||||
CheckboxListTile(
|
||||
value: termsAccepted.value,
|
||||
onChanged: (value) {
|
||||
termsAccepted.value = value ?? false;
|
||||
},
|
||||
title: Text('createAccountAgreeTerms').tr(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => onBack(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const Icon(Symbols.chevron_left), Text('back').tr()],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performNext(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 5: Captcha and Complete
|
||||
class _CreateAccountCompleteScreen extends HookConsumerWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController usernameController;
|
||||
final TextEditingController nicknameController;
|
||||
final TextEditingController affiliationSpellController;
|
||||
final String? onboardingToken;
|
||||
final VoidCallback onBack;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _CreateAccountCompleteScreen({
|
||||
super.key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
required this.usernameController,
|
||||
required this.nicknameController,
|
||||
required this.affiliationSpellController,
|
||||
required this.onboardingToken,
|
||||
required this.onBack,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
Map<String, dynamic> decodeJwt(String token) {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) throw FormatException('Invalid JWT');
|
||||
final payload = parts[1];
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||
return json.decode(decoded);
|
||||
}
|
||||
|
||||
void showPostCreateModal(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => _PostCreateModal(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> performAction() async {
|
||||
String endpoint = '/pass/accounts';
|
||||
Map<String, dynamic> data = {};
|
||||
|
||||
if (onboardingToken != null) {
|
||||
// OIDC onboarding
|
||||
endpoint = '/pass/account/onboard';
|
||||
data['onboarding_token'] = onboardingToken;
|
||||
data['name'] = usernameController.text;
|
||||
data['nick'] = nicknameController.text;
|
||||
} else {
|
||||
// Manual account creation
|
||||
final captchaTk = await CaptchaScreen.show(context);
|
||||
if (captchaTk == null) return;
|
||||
if (!context.mounted) return;
|
||||
data['captcha_token'] = captchaTk;
|
||||
data['name'] = usernameController.text;
|
||||
data['nick'] = nicknameController.text;
|
||||
if (affiliationSpellController.text.isNotEmpty) {
|
||||
data['affiliation_spell'] = affiliationSpellController.text;
|
||||
}
|
||||
data['email'] = emailController.text;
|
||||
data['password'] = passwordController.text;
|
||||
data['language'] =
|
||||
kServerSupportedLanguages[EasyLocalization.of(
|
||||
context,
|
||||
)!.currentLocale.toString()] ??
|
||||
'en-us';
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
try {
|
||||
isBusy.value = true;
|
||||
showLoadingModal(context);
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.post(endpoint, data: data);
|
||||
if (endpoint == '/pass/account/onboard') {
|
||||
// Onboard response has tokens, set them
|
||||
final token = resp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
await userNotifier.fetchUser();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
hideLoadingModal(context);
|
||||
showPostCreateModal(context);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(4),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.check_circle, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'createAccountAlmostThere'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
Text(
|
||||
'createAccountAlmostThereHint'.tr(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
).padding(horizontal: 4),
|
||||
const Gap(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => onBack(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [const Icon(Symbols.chevron_left), Text('back').tr()],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performAction(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('createAccount').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CreateAccountContent extends HookConsumerWidget {
|
||||
const CreateAccountContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final period = useState(0);
|
||||
final onboardingToken = useState<String?>(null);
|
||||
final waitingForOidc = useState(false);
|
||||
|
||||
final emailController = useTextEditingController();
|
||||
final passwordController = useTextEditingController();
|
||||
final usernameController = useTextEditingController();
|
||||
final nicknameController = useTextEditingController();
|
||||
final affiliationSpellController = useTextEditingController();
|
||||
|
||||
Map<String, dynamic> decodeJwt(String token) {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) throw FormatException('Invalid JWT');
|
||||
final payload = parts[1];
|
||||
final normalized = base64Url.normalize(payload);
|
||||
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||
return json.decode(decoded);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||
event,
|
||||
) async {
|
||||
if (!waitingForOidc.value || !context.mounted) return;
|
||||
waitingForOidc.value = false;
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
final resp = await client.post(
|
||||
'/pass/auth/token',
|
||||
data: {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': event.challengeId,
|
||||
},
|
||||
);
|
||||
final data = resp.data;
|
||||
if (data.containsKey('onboarding_token')) {
|
||||
// New user onboarding
|
||||
final token = data['onboarding_token'] as String;
|
||||
final decoded = decodeJwt(token);
|
||||
final name = decoded['name'] as String?;
|
||||
final email = decoded['email'] as String?;
|
||||
final provider = decoded['provider'] as String?;
|
||||
// Pre-fill form and jump to stage 2 (username/nickname)
|
||||
usernameController.text = '';
|
||||
nicknameController.text = name ?? '';
|
||||
emailController.text = email ?? '';
|
||||
passwordController.clear();
|
||||
onboardingToken.value = token;
|
||||
period.value = 2; // Jump to profile screen
|
||||
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
|
||||
} else {
|
||||
// Existing user, switch to login
|
||||
showSnackBar('Account already exists. Redirecting to login.');
|
||||
if (context.mounted) context.goNamed('login');
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
});
|
||||
return subscription.cancel;
|
||||
}, [waitingForOidc.value, context.mounted]);
|
||||
|
||||
Future<void> withOidc(String provider) async {
|
||||
waitingForOidc.value = true;
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final deviceId = await getUdid();
|
||||
final url =
|
||||
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
|
||||
.replace(
|
||||
queryParameters: {
|
||||
'returnUrl': 'solian://auth/callback',
|
||||
'deviceId': deviceId,
|
||||
'flow': 'login',
|
||||
},
|
||||
)
|
||||
.toString();
|
||||
final isLaunched = await launchUrlString(
|
||||
url,
|
||||
mode: kIsWeb
|
||||
? LaunchMode.platformDefault
|
||||
: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!isLaunched) {
|
||||
waitingForOidc.value = false;
|
||||
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||
}
|
||||
}
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isBusy.value)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
)
|
||||
else
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
value: period.value / 5,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: PageTransitionSwitcher(
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 380),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: switch (period.value % 5) {
|
||||
1 => _CreateAccountPasswordScreen(
|
||||
key: const ValueKey(1),
|
||||
passwordController: passwordController,
|
||||
onNext: () => period.value++,
|
||||
onBack: () => period.value--,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
2 => _CreateAccountProfileScreen(
|
||||
key: const ValueKey(2),
|
||||
usernameController: usernameController,
|
||||
nicknameController: nicknameController,
|
||||
isOidcFlow: onboardingToken.value != null,
|
||||
onNext: () => period.value++,
|
||||
onBack: () => period.value--,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
3 => _CreateAccountTermsScreen(
|
||||
key: const ValueKey(3),
|
||||
onNext: () => period.value++,
|
||||
onBack: () => period.value--,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
4 => _CreateAccountCompleteScreen(
|
||||
key: const ValueKey(4),
|
||||
emailController: emailController,
|
||||
passwordController: passwordController,
|
||||
usernameController: usernameController,
|
||||
nicknameController: nicknameController,
|
||||
affiliationSpellController: affiliationSpellController,
|
||||
onboardingToken: onboardingToken.value,
|
||||
onBack: () => period.value--,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
_ => _CreateAccountEmailScreen(
|
||||
key: const ValueKey(0),
|
||||
emailController: emailController,
|
||||
affiliationSpellController: affiliationSpellController,
|
||||
onNext: () => period.value++,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
onOidc: withOidc,
|
||||
),
|
||||
},
|
||||
).padding(all: 24),
|
||||
).center(),
|
||||
),
|
||||
const Gap(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostCreateModal extends HookConsumerWidget {
|
||||
const _PostCreateModal();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('🎉').fontSize(32),
|
||||
Text(
|
||||
'postCreateAccountTitle'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
).fontSize(17),
|
||||
const Gap(18),
|
||||
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
||||
const Gap(4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('\u2022'),
|
||||
Expanded(child: Text('postCreateAccountNext1').tr()),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('\u2022'),
|
||||
Expanded(child: Text('postCreateAccountNext2').tr()),
|
||||
],
|
||||
),
|
||||
const Gap(6),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
context.pushReplacementNamed('login');
|
||||
},
|
||||
child: Text('login'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/auth/create_account_modal.dart
Normal file
19
lib/auth/create_account_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
|
||||
import 'create_account_content.dart';
|
||||
|
||||
class CreateAccountModal extends HookConsumerWidget {
|
||||
const CreateAccountModal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SheetScaffold(
|
||||
titleText: 'createAccount'.tr(),
|
||||
heightFactor: 0.9,
|
||||
child: CreateAccountContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/auth/login.dart
Normal file
35
lib/auth/login.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'login_content.dart';
|
||||
|
||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||
2: (
|
||||
'authFactorInAppNotify',
|
||||
'authFactorInAppNotifyDescription',
|
||||
Symbols.notifications_active,
|
||||
),
|
||||
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
|
||||
};
|
||||
|
||||
class LoginScreen extends HookConsumerWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('login').tr(),
|
||||
),
|
||||
body: LoginContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
812
lib/auth/login_content.dart
Normal file
812
lib/auth/login_content.dart
Normal file
@@ -0,0 +1,812 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/auth/auth_models/auth.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/core/websocket.dart';
|
||||
import 'package:island/accounts/account/me/settings_connections.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/core/services/notify.dart';
|
||||
import 'package:island/core/services/udid.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'captcha.dart';
|
||||
|
||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||
2: (
|
||||
'authFactorInAppNotify',
|
||||
'authFactorInAppNotifyDescription',
|
||||
Symbols.notifications_active,
|
||||
),
|
||||
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
|
||||
};
|
||||
|
||||
/// Performs post-login tasks including fetching user info, subscribing to push
|
||||
/// notifications, connecting websocket, and closing the login dialog.
|
||||
Future<void> performPostLogin(BuildContext context, WidgetRef ref) async {
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
await userNotifier.fetchUser();
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
subscribePushNotification(apiClient);
|
||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||
wsNotifier.connect();
|
||||
if (context.mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginCheckScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? challenge;
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.challenge,
|
||||
required this.factor,
|
||||
required this.onChallenge,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final passwordController = useTextEditingController();
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
Future<void> getToken({String? code}) async {
|
||||
// Get token if challenge is completed
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final tokenResp = await client.post(
|
||||
'/pass/auth/token',
|
||||
data: {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code ?? challenge!.id,
|
||||
},
|
||||
);
|
||||
final token = tokenResp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Do post login tasks
|
||||
await performPostLogin(context, ref);
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
if (challenge != null && challenge?.stepRemain == 0) {
|
||||
Future(() {
|
||||
if (isBusy.value) return;
|
||||
isBusy.value = true;
|
||||
getToken().catchError((err) {
|
||||
showErrorAlert(err);
|
||||
isBusy.value = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [challenge]);
|
||||
|
||||
if (factor == null) {
|
||||
// Logging in by third parties
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.asterisk, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginInProgress'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
const Gap(16),
|
||||
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performCheckTicket() async {
|
||||
final pwd = passwordController.value.text;
|
||||
if (pwd.isEmpty) return;
|
||||
isBusy.value = true;
|
||||
try {
|
||||
// Pass challenge
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.patch(
|
||||
'/pass/auth/challenge/${challenge!.id}',
|
||||
data: {'factor_id': factor!.id, 'password': pwd},
|
||||
);
|
||||
final result = SnAuthChallenge.fromJson(resp.data);
|
||||
onChallenge(result);
|
||||
if (result.stepRemain > 0) {
|
||||
onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
await getToken(code: result.id);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
return;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final width = math.min(380, MediaQuery.of(context).size.width);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.asterisk, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginEnterPassword'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
if ([0].contains(factor!.type))
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
factor!.type == 0
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
labelText: 'password'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
||||
).padding(horizontal: 7)
|
||||
else
|
||||
OtpTextField(
|
||||
showCursor: false,
|
||||
numberOfFields: 6,
|
||||
obscureText: false,
|
||||
showFieldAsBox: true,
|
||||
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
||||
fieldWidth: (width / 6) - 10,
|
||||
onSubmit: (value) {
|
||||
passwordController.text = value;
|
||||
performCheckTicket();
|
||||
},
|
||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||
),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
||||
),
|
||||
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
||||
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginContent extends HookConsumerWidget {
|
||||
const LoginContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
|
||||
final period = useState(0);
|
||||
final currentTicket = useState<SnAuthChallenge?>(null);
|
||||
final factors = useState<List<SnAuthFactor>>([]);
|
||||
final factorPicked = useState<SnAuthFactor?>(null);
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||
child: Column(
|
||||
children: [
|
||||
if (isBusy.value)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
)
|
||||
else if (currentTicket.value != null)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
borderRadius: BorderRadius.zero,
|
||||
trackGap: 0,
|
||||
stopIndicatorRadius: 0,
|
||||
value:
|
||||
1 -
|
||||
(currentTicket.value!.stepRemain /
|
||||
currentTicket.value!.stepTotal),
|
||||
)
|
||||
else
|
||||
const Gap(4),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: PageTransitionSwitcher(
|
||||
transitionBuilder:
|
||||
(
|
||||
Widget child,
|
||||
Animation<double> primaryAnimation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
return SharedAxisTransition(
|
||||
animation: primaryAnimation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 380),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: switch (period.value % 3) {
|
||||
1 => _LoginPickerScreen(
|
||||
key: const ValueKey(1),
|
||||
challenge: currentTicket.value,
|
||||
factors: factors.value,
|
||||
onChallenge: (SnAuthChallenge? p0) =>
|
||||
currentTicket.value = p0,
|
||||
onPickFactor: (SnAuthFactor p0) => factorPicked.value = p0,
|
||||
onNext: () => period.value++,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
2 => _LoginCheckScreen(
|
||||
key: const ValueKey(2),
|
||||
challenge: currentTicket.value,
|
||||
factor: factorPicked.value,
|
||||
onChallenge: (SnAuthChallenge? p0) =>
|
||||
currentTicket.value = p0,
|
||||
onNext: () => period.value = 1,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
_ => _LoginLookupScreen(
|
||||
key: const ValueKey(0),
|
||||
ticket: currentTicket.value,
|
||||
onChallenge: (SnAuthChallenge? p0) =>
|
||||
currentTicket.value = p0,
|
||||
onFactor: (List<SnAuthFactor>? p0) =>
|
||||
factors.value = p0 ?? [],
|
||||
onNext: () => period.value++,
|
||||
onBusy: (value) => isBusy.value = value,
|
||||
),
|
||||
},
|
||||
).padding(all: 24),
|
||||
).center(),
|
||||
),
|
||||
|
||||
const Gap(4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPickerScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? challenge;
|
||||
final List<SnAuthFactor>? factors;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.challenge,
|
||||
required this.factors,
|
||||
required this.onChallenge,
|
||||
required this.onPickFactor,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final factorPicked = useState<SnAuthFactor?>(null);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
useEffect(() {
|
||||
if (challenge != null && challenge?.stepRemain == 0) {
|
||||
Future(() {
|
||||
onNext();
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [challenge]);
|
||||
|
||||
final unfocusColor = Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
final hintController = useTextEditingController();
|
||||
|
||||
void performGetFactorCode() async {
|
||||
if (factorPicked.value == null) return;
|
||||
|
||||
isBusy.value = true;
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
try {
|
||||
await client.post(
|
||||
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
||||
data: hintController.text.isNotEmpty
|
||||
? jsonEncode(hintController.text)
|
||||
: null,
|
||||
);
|
||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
if (err is DioException && err.response?.statusCode == 400) {
|
||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||
onNext();
|
||||
if (context.mounted) {
|
||||
showSnackBar(err.response!.data.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
showErrorAlert(err);
|
||||
return;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
key: const ValueKey<int>(1),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.lock, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginPickFactor'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children:
|
||||
factors
|
||||
?.map(
|
||||
(x) => CheckboxListTile(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
secondary: Icon(
|
||||
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
||||
),
|
||||
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
||||
enabled: !challenge!.blacklistFactors.contains(x.id),
|
||||
value: factorPicked.value == x,
|
||||
onChanged: (value) {
|
||||
if (value == true) {
|
||||
factorPicked.value = x;
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
List.empty(),
|
||||
),
|
||||
),
|
||||
if ([1].contains(factorPicked.value?.type))
|
||||
TextField(
|
||||
controller: hintController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'authFactorHint'.tr(),
|
||||
helperText: 'authFactorHintHelper'.tr(),
|
||||
),
|
||||
).padding(top: 12, bottom: 4, horizontal: 4),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'loginMultiFactor'.plural(challenge!.stepRemain),
|
||||
style: TextStyle(color: unfocusColor, fontSize: 13),
|
||||
).padding(horizontal: 16),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next'.tr()),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginLookupScreen extends HookConsumerWidget {
|
||||
final SnAuthChallenge? ticket;
|
||||
final Function(SnAuthChallenge?) onChallenge;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final VoidCallback onNext;
|
||||
final Function(bool) onBusy;
|
||||
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
required this.onChallenge,
|
||||
required this.onFactor,
|
||||
required this.onNext,
|
||||
required this.onBusy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isBusy = useState(false);
|
||||
final usernameController = useTextEditingController();
|
||||
final waitingForOidc = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
onBusy.call(isBusy.value);
|
||||
return null;
|
||||
}, [isBusy]);
|
||||
|
||||
useEffect(() {
|
||||
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||
event,
|
||||
) async {
|
||||
if (!waitingForOidc.value || !context.mounted) return;
|
||||
waitingForOidc.value = false;
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
final resp = await client.get(
|
||||
'/pass/auth/challenge/${event.challengeId}',
|
||||
);
|
||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||
onChallenge(challenge);
|
||||
final factorResp = await client.get(
|
||||
'/pass/auth/challenge/${challenge.id}/factors',
|
||||
);
|
||||
onFactor(
|
||||
List<SnAuthFactor>.from(
|
||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||
),
|
||||
);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
});
|
||||
return subscription.cancel;
|
||||
}, [waitingForOidc.value, context.mounted]);
|
||||
|
||||
Future<void> requestResetPassword() async {
|
||||
final uname = usernameController.value.text;
|
||||
if (uname.isEmpty) {
|
||||
showErrorAlert('loginResetPasswordHint'.tr());
|
||||
return;
|
||||
}
|
||||
final captchaTk = await CaptchaScreen.show(context);
|
||||
if (captchaTk == null) return;
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.post(
|
||||
'/pass/accounts/recovery/password',
|
||||
data: {'account': uname, 'captcha_token': captchaTk},
|
||||
);
|
||||
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performNewTicket() async {
|
||||
final uname = usernameController.value.text;
|
||||
if (uname.isEmpty) return;
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.post(
|
||||
'/pass/auth/challenge',
|
||||
data: {
|
||||
'account': uname,
|
||||
'device_id': await getUdid(),
|
||||
'device_name': await getDeviceName(),
|
||||
'platform': kIsWeb
|
||||
? 1
|
||||
: switch (defaultTargetPlatform) {
|
||||
TargetPlatform.iOS => 2,
|
||||
TargetPlatform.android => 3,
|
||||
TargetPlatform.macOS => 4,
|
||||
TargetPlatform.windows => 5,
|
||||
TargetPlatform.linux => 6,
|
||||
_ => 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
final result = SnAuthChallenge.fromJson(resp.data);
|
||||
onChallenge(result);
|
||||
final factorResp = await client.get(
|
||||
'/pass/auth/challenge/${result.id}/factors',
|
||||
);
|
||||
onFactor(
|
||||
List<SnAuthFactor>.from(
|
||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||
),
|
||||
);
|
||||
onNext();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
return;
|
||||
} finally {
|
||||
isBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> withApple() async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [AppleIDAuthorizationScopes.email],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'dev.solsynth.solarpass',
|
||||
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) showLoadingModal(context);
|
||||
final resp = await client.post(
|
||||
'/pass/auth/login/apple/mobile',
|
||||
data: {
|
||||
'identity_token': credential.identityToken!,
|
||||
'authorization_code': credential.authorizationCode,
|
||||
'device_id': await getUdid(),
|
||||
'device_name': await getDeviceName(),
|
||||
},
|
||||
);
|
||||
|
||||
final token = resp.data['token'];
|
||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||
ref.invalidate(tokenProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Do post login tasks
|
||||
await performPostLogin(context, ref);
|
||||
} catch (err) {
|
||||
if (err is SignInWithAppleAuthorizationException) return;
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> withOidc(String provider) async {
|
||||
waitingForOidc.value = true;
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final token = ref.watch(tokenProvider);
|
||||
final deviceId = await getUdid();
|
||||
final queryParams = <String, String>{
|
||||
'returnUrl': 'solian://auth/callback',
|
||||
'deviceId': deviceId,
|
||||
'flow': 'login',
|
||||
};
|
||||
if (token?.token != null) {
|
||||
queryParams['token'] = token!.token;
|
||||
}
|
||||
final url = Uri.parse(
|
||||
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
|
||||
).replace(queryParameters: queryParams).toString();
|
||||
final isLaunched = await launchUrlString(
|
||||
url,
|
||||
mode: kIsWeb
|
||||
? LaunchMode.platformDefault
|
||||
: LaunchMode.externalApplication,
|
||||
webOnlyWindowName: token?.token != null
|
||||
? 'auth-${token!.token}'
|
||||
: 'auth',
|
||||
);
|
||||
if (!isLaunched) {
|
||||
waitingForOidc.value = false;
|
||||
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
child: const Icon(Symbols.login, size: 28),
|
||||
).padding(bottom: 8),
|
||||
),
|
||||
Text(
|
||||
'loginGreeting'.tr(),
|
||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||
).padding(left: 4, bottom: 16),
|
||||
TextField(
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
controller: usernameController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'username'.tr(),
|
||||
helperText: 'usernameLookupHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
|
||||
).padding(horizontal: 7),
|
||||
if (!kIsWeb)
|
||||
Row(
|
||||
spacing: 6,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
||||
const Gap(8),
|
||||
Spacer(),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => withOidc('github'),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"github",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'GitHub',
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => withOidc('google'),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"google",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'Google',
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: withApple,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: getProviderIcon(
|
||||
"apple",
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
tooltip: 'Apple Account',
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8, vertical: 8)
|
||||
else
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => requestResetPassword(),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||
child: Text('forgotPassword'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isBusy.value ? null : () => performNewTicket(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('next').tr(),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: StyledWidget(
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink'.tr()),
|
||||
const Gap(4),
|
||||
const Icon(Symbols.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
19
lib/auth/login_modal.dart
Normal file
19
lib/auth/login_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
|
||||
import 'login_content.dart';
|
||||
|
||||
class LoginModal extends HookConsumerWidget {
|
||||
const LoginModal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SheetScaffold(
|
||||
titleText: 'login'.tr(),
|
||||
heightFactor: 0.9,
|
||||
child: LoginContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/auth/oidc.dart
Normal file
1
lib/auth/oidc.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'oidc.native.dart' if (dart.library.html) 'oidc.web.dart';
|
||||
221
lib/auth/oidc.native.dart
Normal file
221
lib/auth/oidc.native.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/udid.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class OidcScreen extends ConsumerStatefulWidget {
|
||||
final String provider;
|
||||
final String? title;
|
||||
|
||||
const OidcScreen({super.key, required this.provider, this.title});
|
||||
|
||||
@override
|
||||
ConsumerState<OidcScreen> createState() => _OidcScreenState();
|
||||
}
|
||||
|
||||
class _OidcScreenState extends ConsumerState<OidcScreen> {
|
||||
String? authToken;
|
||||
String? currentUrl;
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
bool _isLoading = true;
|
||||
late Future<String> _deviceIdFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_deviceIdFuture = getUdid();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_urlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final token = ref.watch(tokenProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
|
||||
),
|
||||
body: FutureBuilder<String>(
|
||||
future: _deviceIdFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('somethingWentWrong').tr());
|
||||
}
|
||||
|
||||
final deviceId = snapshot.data!;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InAppWebView(
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent: kIsWeb
|
||||
? null
|
||||
: Platform.isIOS
|
||||
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
|
||||
: Platform.isAndroid
|
||||
? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
|
||||
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||
),
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri(
|
||||
'$serverUrl/pass/auth/login/${widget.provider}',
|
||||
),
|
||||
headers: {
|
||||
if (token?.token.isNotEmpty ?? false)
|
||||
'Authorization': 'AtField ${token!.token}',
|
||||
'X-Device-Id': deviceId,
|
||||
},
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
// Register a handler to receive the token from JavaScript
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'tokenHandler',
|
||||
callback: (args) {
|
||||
// args[0] will be the token string
|
||||
if (args.isNotEmpty && args[0] is String) {
|
||||
setState(() {
|
||||
authToken = args[0];
|
||||
});
|
||||
|
||||
// Return the token and close the webview
|
||||
Navigator.of(context).pop(authToken);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
shouldOverrideUrlLoading:
|
||||
(controller, navigationAction) async {
|
||||
final url = navigationAction.request.url;
|
||||
if (url != null) {
|
||||
setState(() {
|
||||
currentUrl = url.toString();
|
||||
_urlController.text = currentUrl ?? '';
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final path = url.path;
|
||||
final queryParams = url.queryParameters;
|
||||
|
||||
// Check if we're on the token page
|
||||
if (path.endsWith('/auth/callback')) {
|
||||
// Extract token from URL
|
||||
final challenge = queryParams['challenge'];
|
||||
// Return the token and close the webview
|
||||
Navigator.of(context).pop(challenge);
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
}
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
onUpdateVisitedHistory: (controller, url, androidIsReload) {
|
||||
if (url != null) {
|
||||
setState(() {
|
||||
currentUrl = url.toString();
|
||||
_urlController.text = currentUrl ?? '';
|
||||
});
|
||||
}
|
||||
},
|
||||
onLoadStop: (controller, url) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
},
|
||||
onLoadStart: (controller, url) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
},
|
||||
onLoadError: (controller, url, code, message) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Loading progress indicator
|
||||
if (_isLoading)
|
||||
LinearProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.zero,
|
||||
stopIndicatorRadius: 0,
|
||||
minHeight: 2,
|
||||
)
|
||||
else
|
||||
ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
).height(2),
|
||||
// Debug location bar (only visible in debug mode)
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
top: 8,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _urlController,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
hintText: 'URL',
|
||||
),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
if (currentUrl != null) {
|
||||
Clipboard.setData(ClipboardData(text: currentUrl!));
|
||||
showSnackBar('copyToClipboard'.tr());
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
83
lib/auth/oidc.web.dart
Normal file
83
lib/auth/oidc.web.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
// ignore_for_file: invalid_runtime_check_with_js_interop_types
|
||||
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class OidcScreen extends ConsumerStatefulWidget {
|
||||
final String provider;
|
||||
final String? title;
|
||||
|
||||
const OidcScreen({super.key, required this.provider, this.title});
|
||||
|
||||
@override
|
||||
ConsumerState<OidcScreen> createState() => _OidcScreenState();
|
||||
}
|
||||
|
||||
class _OidcScreenState extends ConsumerState<OidcScreen> {
|
||||
bool _isInitialized = false;
|
||||
final String _viewType = 'oidc-iframe';
|
||||
|
||||
void _setupWebListener(String serverUrl) {
|
||||
// Listen for messages from the iframe
|
||||
web.window.onMessage.listen((event) {
|
||||
if (event.data != null && event.data is String) {
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("token=")) {
|
||||
String token = message.replaceFirst("token=", "");
|
||||
// Return the token and close the screen
|
||||
if (mounted) Navigator.pop(context, token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create the iframe for the OIDC login
|
||||
final token = ref.watch(tokenProvider);
|
||||
final iframe = web.HTMLIFrameElement()
|
||||
..src = (token?.token.isNotEmpty ?? false)
|
||||
? '$serverUrl/auth/login/${widget.provider}?tk=${token!.token}'
|
||||
: '$serverUrl/auth/login/${widget.provider}'
|
||||
..style.border = 'none'
|
||||
..width = '100%'
|
||||
..height = '100%';
|
||||
|
||||
// Add the iframe to the document body
|
||||
web.document.body!.append(iframe);
|
||||
|
||||
// Register the iframe as a platform view
|
||||
ui.platformViewRegistry.registerViewFactory(
|
||||
_viewType,
|
||||
(int viewId) => iframe,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(Duration.zero, () {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
_setupWebListener(serverUrl);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
|
||||
),
|
||||
body: _isInitialized
|
||||
? HtmlElementView(viewType: _viewType)
|
||||
: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/auth/web_auth/web_auth_providers.dart
Normal file
56
lib/auth/web_auth/web_auth_providers.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'web_auth_server.dart';
|
||||
|
||||
class WebAuthServerState {
|
||||
final bool isRunning;
|
||||
final int? port;
|
||||
final Object? error;
|
||||
|
||||
WebAuthServerState({this.isRunning = false, this.port, this.error});
|
||||
|
||||
WebAuthServerState copyWith({
|
||||
bool? isRunning,
|
||||
int? port,
|
||||
Object? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return WebAuthServerState(
|
||||
isRunning: isRunning ?? this.isRunning,
|
||||
port: port ?? this.port,
|
||||
error: clearError ? null : error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WebAuthServerNotifier extends Notifier<WebAuthServerState> {
|
||||
late final WebAuthServer _server;
|
||||
|
||||
@override
|
||||
WebAuthServerState build() {
|
||||
_server = ref.watch(webAuthServerProvider);
|
||||
return WebAuthServerState();
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
try {
|
||||
final port = await _server.start();
|
||||
state = state.copyWith(isRunning: true, port: port, clearError: true);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isRunning: false, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_server.stop();
|
||||
state = state.copyWith(isRunning: false, port: null);
|
||||
}
|
||||
}
|
||||
|
||||
final webAuthServerProvider = Provider<WebAuthServer>((ref) {
|
||||
return WebAuthServer(ref);
|
||||
});
|
||||
|
||||
final webAuthServerStateProvider =
|
||||
NotifierProvider<WebAuthServerNotifier, WebAuthServerState>(
|
||||
WebAuthServerNotifier.new,
|
||||
);
|
||||
186
lib/auth/web_auth/web_auth_server.dart
Normal file
186
lib/auth/web_auth/web_auth_server.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class WebAuthServer {
|
||||
final Ref _ref;
|
||||
HttpServer? _server;
|
||||
String? _challenge;
|
||||
DateTime? _challengeTimestamp;
|
||||
|
||||
final _challengeTtl = const Duration(seconds: 30);
|
||||
|
||||
WebAuthServer(this._ref);
|
||||
|
||||
Future<int> start() async {
|
||||
if (_server != null) {
|
||||
talker.warning('Web auth server already running.');
|
||||
return _server!.port;
|
||||
}
|
||||
|
||||
final port = await _findUnusedPort(40000, 41000);
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
||||
talker.info('Web auth server started on http://127.0.0.1:$port');
|
||||
|
||||
_server!.listen(_handleRequest);
|
||||
return port;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_server?.close(force: true);
|
||||
_server = null;
|
||||
talker.info('Web auth server stopped.');
|
||||
}
|
||||
|
||||
Future<int> _findUnusedPort(int start, int end) async {
|
||||
for (var port = start; port <= end; port++) {
|
||||
try {
|
||||
var socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, port);
|
||||
await socket.close();
|
||||
return port;
|
||||
} catch (e) {
|
||||
// Port is in use, try next
|
||||
}
|
||||
}
|
||||
throw Exception('No unused port found in range $start-$end');
|
||||
}
|
||||
|
||||
String _generateChallenge() {
|
||||
final random = Random.secure();
|
||||
final values = List<int>.generate(32, (i) => random.nextInt(256));
|
||||
return base64Url.encode(values);
|
||||
}
|
||||
|
||||
void _addCorsHeaders(HttpResponse response) {
|
||||
const webUrl = 'https://app.solian.fr';
|
||||
|
||||
response.headers.add('Access-Control-Allow-Origin', webUrl);
|
||||
response.headers.add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
response.headers.add('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
Future<void> _handleRequest(HttpRequest request) async {
|
||||
try {
|
||||
_addCorsHeaders(request.response);
|
||||
|
||||
if (request.method == 'OPTIONS') {
|
||||
request.response.statusCode = HttpStatus.noContent;
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
talker.info('Web auth request: ${request.method} ${request.uri.path}');
|
||||
|
||||
if (request.method == 'GET' && request.uri.path == '/alive') {
|
||||
await _handleAlive(request);
|
||||
} else if (request.method == 'POST' && request.uri.path == '/exchange') {
|
||||
await _handleExchange(request);
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
request.response.write(jsonEncode({'error': 'Not Found'}));
|
||||
await request.response.close();
|
||||
}
|
||||
} catch (e, st) {
|
||||
talker.handle(e, st, 'Error handling web auth request');
|
||||
try {
|
||||
request.response.statusCode = HttpStatus.internalServerError;
|
||||
request.response.write(jsonEncode({'error': 'Internal Server Error'}));
|
||||
await request.response.close();
|
||||
} catch (e2) {
|
||||
talker.error('Failed to send error response: $e2');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAlive(HttpRequest request) async {
|
||||
_challenge = _generateChallenge();
|
||||
_challengeTimestamp = DateTime.now();
|
||||
|
||||
final response = {
|
||||
'status': 'ok',
|
||||
'challenge': _challenge,
|
||||
};
|
||||
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(jsonEncode(response));
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
Future<void> _handleExchange(HttpRequest request) async {
|
||||
if (_challenge == null ||
|
||||
_challengeTimestamp == null ||
|
||||
DateTime.now().difference(_challengeTimestamp!) > _challengeTtl) {
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
request.response.write(jsonEncode({
|
||||
'error': 'Invalid or expired challenge. Please call /alive first.'
|
||||
}));
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final requestBody = await utf8.decodeStream(request);
|
||||
final Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(requestBody);
|
||||
} catch (e) {
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
request.response.write(jsonEncode({'error': 'Invalid JSON body'}));
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final String? signedChallenge = data['signedChallenge'];
|
||||
final Map<String, dynamic>? deviceInfo = data['deviceInfo'];
|
||||
|
||||
if (signedChallenge == null) {
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
request.response.write(jsonEncode({'error': 'Missing signedChallenge'}));
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
final currentChallenge = _challenge!;
|
||||
_challenge = null;
|
||||
_challengeTimestamp = null;
|
||||
|
||||
try {
|
||||
final dio = _ref.read(apiClientProvider);
|
||||
|
||||
final response = await dio.post(
|
||||
'/pass/auth/login/session',
|
||||
data: {
|
||||
'signedChallenge': signedChallenge,
|
||||
'challenge': currentChallenge,
|
||||
...?deviceInfo,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final webToken = response.data['token'];
|
||||
request.response.statusCode = HttpStatus.ok;
|
||||
request.response.write(jsonEncode({'token': webToken}));
|
||||
} else {
|
||||
throw Exception(
|
||||
'Backend exchange failed with status ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
talker.error('Backend exchange failed: ${e.response?.data}');
|
||||
request.response.statusCode =
|
||||
e.response?.statusCode ?? HttpStatus.internalServerError;
|
||||
request.response.write(
|
||||
jsonEncode(e.response?.data ?? {'error': 'Backend communication failed'}));
|
||||
} catch (e, st) {
|
||||
talker.handle(e, st, 'Error during backend exchange');
|
||||
request.response.statusCode = HttpStatus.internalServerError;
|
||||
request.response
|
||||
.write(jsonEncode({'error': 'An unexpected error occurred'}));
|
||||
} finally {
|
||||
await request.response.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user