✨ Brand new sign in flow
This commit is contained in:
parent
73456fcff6
commit
c18ce88993
@ -3,6 +3,7 @@
|
|||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"okay": "Okay",
|
"okay": "Okay",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"prev": "Previous",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"page": "Page",
|
"page": "Page",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@ -70,8 +71,12 @@
|
|||||||
"forgotPassword": "Forgot password",
|
"forgotPassword": "Forgot password",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
"usernameInputHint": "Also supports email and phone number",
|
||||||
"nickname": "Nickname",
|
"nickname": "Nickname",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"passwordOneTime": "One-time-password",
|
||||||
|
"passwordInputHint": "Forgot your password? Go back to the first step to reset your password",
|
||||||
|
"passwordOneTimeInputHint": "Check your inbox or authorizer for a verification code",
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"birthday": "Birthday",
|
"birthday": "Birthday",
|
||||||
@ -103,6 +108,11 @@
|
|||||||
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
|
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
|
||||||
"signinResetPasswordHint": "Please enter username to request reset password.",
|
"signinResetPasswordHint": "Please enter username to request reset password.",
|
||||||
"signinResetPasswordSent": "Reset password request sent, check your inbox!",
|
"signinResetPasswordSent": "Reset password request sent, check your inbox!",
|
||||||
|
"signinPickFactor": "Pick a way\nfor verification",
|
||||||
|
"signinEnterPassword": "Enter your\npassword",
|
||||||
|
"signinMultiFactor": "@n step(s) verifications",
|
||||||
|
"authFactorEmail": "Email One-time-password",
|
||||||
|
"authFactorPassword": "Password",
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
"signupGreeting": "Welcome onboard",
|
"signupGreeting": "Welcome onboard",
|
||||||
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
|
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
"okay": "确认",
|
"okay": "确认",
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"next": "下一步",
|
"next": "下一步",
|
||||||
|
"prev": "上一步",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@ -75,8 +76,12 @@
|
|||||||
"forgotPassword": "忘记密码",
|
"forgotPassword": "忘记密码",
|
||||||
"email": "邮件地址",
|
"email": "邮件地址",
|
||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
|
"usernameInputHint": "同时支持邮箱 / 电话号码",
|
||||||
"nickname": "显示名",
|
"nickname": "显示名",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"passwordOneTime": "一次性验证码",
|
||||||
|
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
|
||||||
|
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
|
||||||
"title": "标题",
|
"title": "标题",
|
||||||
"description": "简介",
|
"description": "简介",
|
||||||
"birthday": "生日",
|
"birthday": "生日",
|
||||||
@ -108,6 +113,11 @@
|
|||||||
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
|
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
|
||||||
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
|
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
|
||||||
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
|
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
|
||||||
|
"signinPickFactor": "选择一个\n验证方式",
|
||||||
|
"signinEnterPassword": "输入密码\n或验证码",
|
||||||
|
"signinMultiFactor": "@n 步验证",
|
||||||
|
"authFactorEmail": "邮箱一次性密码",
|
||||||
|
"authFactorPassword": "账户密码",
|
||||||
"signup": "注册",
|
"signup": "注册",
|
||||||
"signupGreeting": "欢迎加入\nSolar Network",
|
"signupGreeting": "欢迎加入\nSolar Network",
|
||||||
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
|
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
|
||||||
|
103
lib/models/auth.dart
Normal file
103
lib/models/auth.dart
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:solian/models/account.dart';
|
||||||
|
|
||||||
|
part 'auth.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class AuthResult {
|
||||||
|
bool isFinished;
|
||||||
|
AuthTicket ticket;
|
||||||
|
|
||||||
|
AuthResult({
|
||||||
|
required this.isFinished,
|
||||||
|
required this.ticket,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AuthResultFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$AuthResultToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class AuthTicket {
|
||||||
|
int id;
|
||||||
|
DateTime createdAt;
|
||||||
|
DateTime updatedAt;
|
||||||
|
DateTime? deletedAt;
|
||||||
|
String location;
|
||||||
|
String ipAddress;
|
||||||
|
String userAgent;
|
||||||
|
int stepRemain;
|
||||||
|
List<String> claims;
|
||||||
|
List<String> audiences;
|
||||||
|
@JsonKey(defaultValue: [])
|
||||||
|
List<int> factorTrail;
|
||||||
|
String? grantToken;
|
||||||
|
String? accessToken;
|
||||||
|
String? refreshToken;
|
||||||
|
DateTime? expiredAt;
|
||||||
|
DateTime? availableAt;
|
||||||
|
DateTime? lastGrantAt;
|
||||||
|
String? nonce;
|
||||||
|
int? clientId;
|
||||||
|
Account account;
|
||||||
|
int accountId;
|
||||||
|
|
||||||
|
AuthTicket({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.deletedAt,
|
||||||
|
required this.location,
|
||||||
|
required this.ipAddress,
|
||||||
|
required this.userAgent,
|
||||||
|
required this.stepRemain,
|
||||||
|
required this.claims,
|
||||||
|
required this.audiences,
|
||||||
|
required this.factorTrail,
|
||||||
|
required this.grantToken,
|
||||||
|
required this.accessToken,
|
||||||
|
required this.refreshToken,
|
||||||
|
required this.expiredAt,
|
||||||
|
required this.availableAt,
|
||||||
|
required this.lastGrantAt,
|
||||||
|
required this.nonce,
|
||||||
|
required this.clientId,
|
||||||
|
required this.account,
|
||||||
|
required this.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthTicket.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AuthTicketFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$AuthTicketToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class AuthFactor {
|
||||||
|
int id;
|
||||||
|
DateTime createdAt;
|
||||||
|
DateTime updatedAt;
|
||||||
|
DateTime? deletedAt;
|
||||||
|
int type;
|
||||||
|
Map<String, dynamic>? config;
|
||||||
|
Account account;
|
||||||
|
int accountId;
|
||||||
|
|
||||||
|
AuthFactor({
|
||||||
|
required this.id,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.deletedAt,
|
||||||
|
required this.type,
|
||||||
|
required this.config,
|
||||||
|
required this.account,
|
||||||
|
required this.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthFactor.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$AuthFactorFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$AuthFactorToJson(this);
|
||||||
|
}
|
105
lib/models/auth.g.dart
Normal file
105
lib/models/auth.g.dart
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'auth.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
AuthResult _$AuthResultFromJson(Map<String, dynamic> json) => AuthResult(
|
||||||
|
isFinished: json['is_finished'] as bool,
|
||||||
|
ticket: AuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$AuthResultToJson(AuthResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'is_finished': instance.isFinished,
|
||||||
|
'ticket': instance.ticket.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthTicket _$AuthTicketFromJson(Map<String, dynamic> json) => AuthTicket(
|
||||||
|
id: (json['id'] 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),
|
||||||
|
location: json['location'] as String,
|
||||||
|
ipAddress: json['ip_address'] as String,
|
||||||
|
userAgent: json['user_agent'] as String,
|
||||||
|
stepRemain: (json['step_remain'] as num).toInt(),
|
||||||
|
claims:
|
||||||
|
(json['claims'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
|
audiences:
|
||||||
|
(json['audiences'] as List<dynamic>).map((e) => e as String).toList(),
|
||||||
|
factorTrail: (json['factor_trail'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
|
grantToken: json['grant_token'] as String?,
|
||||||
|
accessToken: json['access_token'] as String?,
|
||||||
|
refreshToken: json['refresh_token'] as String?,
|
||||||
|
expiredAt: json['expired_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['expired_at'] as String),
|
||||||
|
availableAt: json['available_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['available_at'] as String),
|
||||||
|
lastGrantAt: json['last_grant_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['last_grant_at'] as String),
|
||||||
|
nonce: json['nonce'] as String?,
|
||||||
|
clientId: (json['client_id'] as num?)?.toInt(),
|
||||||
|
account: Account.fromJson(json['account'] as Map<String, dynamic>),
|
||||||
|
accountId: (json['account_id'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$AuthTicketToJson(AuthTicket instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
'location': instance.location,
|
||||||
|
'ip_address': instance.ipAddress,
|
||||||
|
'user_agent': instance.userAgent,
|
||||||
|
'step_remain': instance.stepRemain,
|
||||||
|
'claims': instance.claims,
|
||||||
|
'audiences': instance.audiences,
|
||||||
|
'factor_trail': instance.factorTrail,
|
||||||
|
'grant_token': instance.grantToken,
|
||||||
|
'access_token': instance.accessToken,
|
||||||
|
'refresh_token': instance.refreshToken,
|
||||||
|
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||||
|
'available_at': instance.availableAt?.toIso8601String(),
|
||||||
|
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
|
||||||
|
'nonce': instance.nonce,
|
||||||
|
'client_id': instance.clientId,
|
||||||
|
'account': instance.account.toJson(),
|
||||||
|
'account_id': instance.accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthFactor _$AuthFactorFromJson(Map<String, dynamic> json) => AuthFactor(
|
||||||
|
id: (json['id'] 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),
|
||||||
|
type: (json['type'] as num).toInt(),
|
||||||
|
config: json['config'] as Map<String, dynamic>?,
|
||||||
|
account: Account.fromJson(json['account'] as Map<String, dynamic>),
|
||||||
|
accountId: (json['account_id'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$AuthFactorToJson(AuthFactor instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
'type': instance.type,
|
||||||
|
'config': instance.config,
|
||||||
|
'account': instance.account.toJson(),
|
||||||
|
'account_id': instance.accountId,
|
||||||
|
};
|
@ -9,6 +9,7 @@ import 'package:get/get_connect/http/src/request/request.dart';
|
|||||||
import 'package:solian/background.dart';
|
import 'package:solian/background.dart';
|
||||||
import 'package:solian/exceptions/request.dart';
|
import 'package:solian/exceptions/request.dart';
|
||||||
import 'package:solian/exceptions/unauthorized.dart';
|
import 'package:solian/exceptions/unauthorized.dart';
|
||||||
|
import 'package:solian/models/auth.dart';
|
||||||
import 'package:solian/providers/database/database.dart';
|
import 'package:solian/providers/database/database.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/providers/websocket.dart';
|
||||||
import 'package:solian/services.dart';
|
import 'package:solian/services.dart';
|
||||||
@ -148,27 +149,13 @@ class AuthProvider extends GetConnect {
|
|||||||
|
|
||||||
Future<TokenSet> signin(
|
Future<TokenSet> signin(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String username,
|
AuthTicket ticket,
|
||||||
String password,
|
|
||||||
) async {
|
) async {
|
||||||
userProfile.value = null;
|
userProfile.value = null;
|
||||||
|
|
||||||
final client = ServiceFinder.configureClient('auth');
|
|
||||||
|
|
||||||
// Create ticket
|
|
||||||
final resp = await client.post('/auth', {
|
|
||||||
'username': username,
|
|
||||||
'password': password,
|
|
||||||
});
|
|
||||||
if (resp.statusCode != 200) {
|
|
||||||
throw RequestException(resp);
|
|
||||||
} else if (resp.body['is_finished'] == false) {
|
|
||||||
throw RiskyAuthenticateException(resp.body['ticket']['id']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign token
|
// Assign token
|
||||||
final tokenResp = await post('/auth/token', {
|
final tokenResp = await post('/auth/token', {
|
||||||
'code': resp.body['ticket']['grant_token'],
|
'code': ticket.grantToken!,
|
||||||
'grant_type': 'grant_token',
|
'grant_type': 'grant_token',
|
||||||
});
|
});
|
||||||
if (tokenResp.statusCode != 200) {
|
if (tokenResp.statusCode != 200) {
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
|
import 'package:animations/animations.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:protocol_handler/protocol_handler.dart';
|
|
||||||
import 'package:solian/background.dart';
|
import 'package:solian/background.dart';
|
||||||
|
import 'package:solian/exceptions/request.dart';
|
||||||
import 'package:solian/exts.dart';
|
import 'package:solian/exts.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/models/auth.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
|
import 'package:solian/providers/websocket.dart';
|
||||||
import 'package:solian/services.dart';
|
import 'package:solian/services.dart';
|
||||||
import 'package:solian/widgets/sized_container.dart';
|
import 'package:solian/widgets/sized_container.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
class SignInScreen extends StatefulWidget {
|
class SignInScreen extends StatefulWidget {
|
||||||
const SignInScreen({super.key});
|
const SignInScreen({super.key});
|
||||||
@ -18,12 +18,28 @@ class SignInScreen extends StatefulWidget {
|
|||||||
State<SignInScreen> createState() => _SignInScreenState();
|
State<SignInScreen> createState() => _SignInScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SignInScreenState extends State<SignInScreen> with ProtocolListener {
|
class _SignInScreenState extends State<SignInScreen> {
|
||||||
bool _isBusy = false;
|
bool _isBusy = false;
|
||||||
|
|
||||||
|
AuthTicket? _currentTicket;
|
||||||
|
|
||||||
|
List<AuthFactor>? _factors;
|
||||||
|
int? _factorPicked;
|
||||||
|
int? _factorPickedType;
|
||||||
|
|
||||||
|
int _period = 0;
|
||||||
|
|
||||||
final _usernameController = TextEditingController();
|
final _usernameController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
|
||||||
|
0: ('authFactorPassword'.tr, Icons.password, false),
|
||||||
|
1: ('authFactorEmail'.tr, Icons.email, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
Color get _unFocusColor =>
|
||||||
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||||
|
|
||||||
void _requestResetPassword() async {
|
void _requestResetPassword() async {
|
||||||
final username = _usernameController.value.text;
|
final username = _usernameController.value.text;
|
||||||
if (username.isEmpty) {
|
if (username.isEmpty) {
|
||||||
@ -54,78 +70,141 @@ class _SignInScreenState extends State<SignInScreen> with ProtocolListener {
|
|||||||
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
|
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _performAction() async {
|
void _performNewTicket() async {
|
||||||
final AuthProvider auth = Get.find();
|
|
||||||
|
|
||||||
final username = _usernameController.value.text;
|
final username = _usernameController.value.text;
|
||||||
final password = _passwordController.value.text;
|
if (username.isEmpty) return;
|
||||||
if (username.isEmpty || password.isEmpty) return;
|
|
||||||
|
final client = ServiceFinder.configureClient('auth');
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.signin(context, username, password);
|
// Create ticket
|
||||||
await Future.delayed(const Duration(milliseconds: 250), () async {
|
final resp = await client.post('/auth', {
|
||||||
await auth.refreshAuthorizeStatus();
|
'username': username,
|
||||||
await auth.refreshUserProfile();
|
|
||||||
});
|
});
|
||||||
} on RiskyAuthenticateException catch (e) {
|
if (resp.statusCode != 200) {
|
||||||
showDialog(
|
throw RequestException(resp);
|
||||||
context: context,
|
} else {
|
||||||
builder: (context) {
|
final result = AuthResult.fromJson(resp.body);
|
||||||
return AlertDialog(
|
_currentTicket = result.ticket;
|
||||||
title: Text('riskDetection'.tr),
|
}
|
||||||
content: Text('signinRiskDetected'.tr),
|
|
||||||
actions: [
|
// Pull factors
|
||||||
TextButton(
|
final factorResp = await client.get('/auth/factors',
|
||||||
child: Text('next'.tr),
|
query: {'ticketId': _currentTicket!.id.toString()});
|
||||||
onPressed: () {
|
if (factorResp.statusCode != 200) {
|
||||||
const redirect = 'solink://auth?status=done';
|
throw RequestException(factorResp);
|
||||||
launchUrlString(
|
} else {
|
||||||
ServiceFinder.buildUrl('capital',
|
final result = List<AuthFactor>.from(
|
||||||
'/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'),
|
factorResp.body.map((x) => AuthFactor.fromJson(x)),
|
||||||
mode: LaunchMode.inAppWebView,
|
);
|
||||||
);
|
_factors = result;
|
||||||
Navigator.pop(context);
|
}
|
||||||
},
|
|
||||||
)
|
setState(() => _period++);
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
context.showErrorDialog(e);
|
context.showErrorDialog(e);
|
||||||
return;
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isBusy = false);
|
setState(() => _isBusy = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Get.find<WebSocketProvider>().registerPushNotifications();
|
|
||||||
autoConfigureBackgroundNotificationService();
|
|
||||||
autoStartBackgroundNotificationService();
|
|
||||||
|
|
||||||
Navigator.pop(context, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _performGetFactorCode() async {
|
||||||
void initState() {
|
if (_factorPicked == null) return;
|
||||||
protocolHandler.addListener(this);
|
|
||||||
super.initState();
|
final client = ServiceFinder.configureClient('auth');
|
||||||
|
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request one-time-password code
|
||||||
|
final resp = await client.post('/auth/factors/$_factorPicked', {});
|
||||||
|
if (resp.statusCode != 200 && resp.statusCode != 204) {
|
||||||
|
throw RequestException(resp);
|
||||||
|
} else {
|
||||||
|
_factorPickedType = _factors!
|
||||||
|
.where(
|
||||||
|
(x) => x.id == _factorPicked,
|
||||||
|
)
|
||||||
|
.first
|
||||||
|
.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _period++);
|
||||||
|
} catch (e) {
|
||||||
|
context.showErrorDialog(e);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _performCheckTicket() async {
|
||||||
void dispose() {
|
final AuthProvider auth = Get.find();
|
||||||
protocolHandler.removeListener(this);
|
|
||||||
super.dispose();
|
final password = _passwordController.value.text;
|
||||||
|
if (password.isEmpty) return;
|
||||||
|
|
||||||
|
final client = ServiceFinder.configureClient('auth');
|
||||||
|
|
||||||
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check ticket
|
||||||
|
final resp = await client.patch('/auth', {
|
||||||
|
'ticket_id': _currentTicket!.id,
|
||||||
|
'factor_id': _factorPicked!,
|
||||||
|
'code': password,
|
||||||
|
});
|
||||||
|
if (resp.statusCode != 200) {
|
||||||
|
throw RequestException(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = AuthResult.fromJson(resp.body);
|
||||||
|
_currentTicket = result.ticket;
|
||||||
|
|
||||||
|
// Finish sign in if possible
|
||||||
|
if (result.isFinished) {
|
||||||
|
await auth.signin(context, _currentTicket!);
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 250), () async {
|
||||||
|
await auth.refreshAuthorizeStatus();
|
||||||
|
await auth.refreshUserProfile();
|
||||||
|
|
||||||
|
Get.find<WebSocketProvider>().registerPushNotifications();
|
||||||
|
autoConfigureBackgroundNotificationService();
|
||||||
|
autoStartBackgroundNotificationService();
|
||||||
|
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Skip the first step
|
||||||
|
_factorPicked = null;
|
||||||
|
_factorPickedType = null;
|
||||||
|
setState(() => _period += 2);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
context.showErrorDialog(e);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setState(() => _isBusy = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _previousStep() {
|
||||||
void onProtocolUrlReceived(String url) {
|
assert(_period > 0);
|
||||||
final uri = url.replaceFirst('solink://', '');
|
switch (_period % 3) {
|
||||||
if (uri == 'auth?status=done') {
|
case 1:
|
||||||
closeInAppWebView();
|
_currentTicket = null;
|
||||||
_performAction();
|
_factors = null;
|
||||||
|
_factorPicked = null;
|
||||||
|
case 2:
|
||||||
|
_passwordController.clear();
|
||||||
|
_factorPickedType = null;
|
||||||
|
default:
|
||||||
|
setState(() => _period--);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,72 +214,237 @@ class _SignInScreenState extends State<SignInScreen> with ProtocolListener {
|
|||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
child: CenteredContainer(
|
child: CenteredContainer(
|
||||||
maxWidth: 360,
|
maxWidth: 360,
|
||||||
child: Column(
|
child: PageTransitionSwitcher(
|
||||||
mainAxisSize: MainAxisSize.min,
|
transitionBuilder: (
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Widget child,
|
||||||
children: [
|
Animation<double> primaryAnimation,
|
||||||
ClipRRect(
|
Animation<double> secondaryAnimation,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
) {
|
||||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
return SharedAxisTransition(
|
||||||
).paddingOnly(bottom: 4),
|
animation: primaryAnimation,
|
||||||
Text(
|
secondaryAnimation: secondaryAnimation,
|
||||||
'signinGreeting'.tr,
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
style: const TextStyle(
|
child: child,
|
||||||
fontSize: 28,
|
);
|
||||||
fontWeight: FontWeight.w900,
|
},
|
||||||
),
|
child: switch (_period % 3) {
|
||||||
).paddingOnly(left: 4, bottom: 16),
|
1 => Column(
|
||||||
TextField(
|
key: const ValueKey<int>(1),
|
||||||
autocorrect: false,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
enableSuggestions: false,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
controller: _usernameController,
|
children: [
|
||||||
autofillHints: const [AutofillHints.username],
|
ClipRRect(
|
||||||
decoration: InputDecoration(
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
isDense: true,
|
child:
|
||||||
border: const OutlineInputBorder(),
|
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||||
labelText: 'username'.tr,
|
).paddingOnly(bottom: 8, left: 4),
|
||||||
),
|
Text(
|
||||||
onTapOutside: (_) =>
|
'signinPickFactor'.tr,
|
||||||
FocusManager.instance.primaryFocus?.unfocus(),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 28,
|
||||||
const Gap(12),
|
fontWeight: FontWeight.w900,
|
||||||
TextField(
|
),
|
||||||
obscureText: true,
|
).paddingOnly(left: 4, bottom: 16),
|
||||||
autocorrect: false,
|
Card(
|
||||||
enableSuggestions: false,
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
autofillHints: const [AutofillHints.password],
|
child: Column(
|
||||||
controller: _passwordController,
|
children: _factors
|
||||||
decoration: InputDecoration(
|
?.map(
|
||||||
isDense: true,
|
(x) => CheckboxListTile(
|
||||||
border: const OutlineInputBorder(),
|
shape: const RoundedRectangleBorder(
|
||||||
labelText: 'password'.tr,
|
borderRadius: BorderRadius.all(
|
||||||
),
|
Radius.circular(8),
|
||||||
onTapOutside: (_) =>
|
),
|
||||||
FocusManager.instance.primaryFocus?.unfocus(),
|
),
|
||||||
onSubmitted: (_) => _performAction(),
|
secondary: Icon(
|
||||||
),
|
_factorLabelMap[x.type]?.$2 ??
|
||||||
const Gap(12),
|
Icons.question_mark,
|
||||||
Row(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
title: Text(
|
||||||
children: [
|
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr,
|
||||||
TextButton(
|
),
|
||||||
onPressed: _isBusy ? null : () => _requestResetPassword(),
|
enabled: !_currentTicket!.factorTrail
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
.contains(x.id),
|
||||||
child: Text('forgotPassword'.tr),
|
value: _factorPicked == x.id,
|
||||||
),
|
onChanged: (value) {
|
||||||
TextButton(
|
if (value == true) {
|
||||||
onPressed: _isBusy ? null : () => _performAction(),
|
setState(() => _factorPicked = x.id);
|
||||||
child: Row(
|
}
|
||||||
mainAxisSize: MainAxisSize.min,
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
List.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'signinMultiFactor'.trParams(
|
||||||
|
{'n': _currentTicket!.stepRemain.toString()},
|
||||||
|
),
|
||||||
|
style: TextStyle(color: _unFocusColor, fontSize: 12),
|
||||||
|
).paddingOnly(left: 16, right: 16),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('next'.tr),
|
TextButton(
|
||||||
const Icon(Icons.chevron_right),
|
onPressed: _isBusy ? null : () => _previousStep(),
|
||||||
|
style:
|
||||||
|
TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chevron_left),
|
||||||
|
Text('prev'.tr),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
_isBusy ? null : () => _performGetFactorCode(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next'.tr),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
2 => Column(
|
||||||
],
|
key: const ValueKey<int>(2),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child:
|
||||||
|
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||||
|
).paddingOnly(bottom: 8, left: 4),
|
||||||
|
Text(
|
||||||
|
'signinEnterPassword'.tr,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
).paddingOnly(left: 4, bottom: 16),
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: [
|
||||||
|
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
|
||||||
|
? AutofillHints.password
|
||||||
|
: AutofillHints.oneTimeCode
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText:
|
||||||
|
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
|
||||||
|
? 'passwordOneTime'.tr
|
||||||
|
: 'password'.tr,
|
||||||
|
helperText:
|
||||||
|
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
|
||||||
|
? 'passwordOneTimeInputHint'.tr
|
||||||
|
: 'passwordInputHint'.tr,
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isBusy ? null : () => _previousStep(),
|
||||||
|
style:
|
||||||
|
TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.chevron_left),
|
||||||
|
Text('prev'.tr),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isBusy ? null : () => _performCheckTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next'.tr),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_ => Column(
|
||||||
|
key: const ValueKey<int>(0),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child:
|
||||||
|
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||||
|
).paddingOnly(bottom: 8, left: 4),
|
||||||
|
Text(
|
||||||
|
'signinGreeting'.tr,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
).paddingOnly(left: 4, bottom: 16),
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: _usernameController,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'username'.tr,
|
||||||
|
helperText: 'usernameInputHint'.tr,
|
||||||
|
),
|
||||||
|
onTapOutside: (_) =>
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
_isBusy ? null : () => _requestResetPassword(),
|
||||||
|
style:
|
||||||
|
TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||||
|
child: Text('forgotPassword'.tr),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isBusy ? null : () => _performNewTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next'.tr),
|
||||||
|
const Icon(Icons.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -73,7 +73,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
||||||
).paddingOnly(bottom: 4),
|
).paddingOnly(bottom: 8, left: 4),
|
||||||
Text(
|
Text(
|
||||||
'signupGreeting'.tr,
|
'signupGreeting'.tr,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
abstract class ServiceFinder {
|
abstract class ServiceFinder {
|
||||||
static const bool devFlag = false;
|
static const bool devFlag = true;
|
||||||
|
|
||||||
static const String dealerUrl =
|
static const String dealerUrl =
|
||||||
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
|
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
|
||||||
|
Loading…
Reference in New Issue
Block a user