mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-18 10:21:03 +03:00
* fix(flutter): show error and retry when fetching login options fails The third-party login section of the login dialog was silently hidden whenever /api/login-options could not be fetched (e.g. TLS handshake aborted by a router/ISP scam filter, discussion #15700), leaving users staring at a dialog with no feedback. The pure-Dart HTTP path also had no timeout, so a black-holed connection could hang indefinitely. - let transport errors propagate from queryOidcLoginOptions instead of swallowing them; a non-JSON response still means "no third-party login" so self-hosted servers without this API keep the old behavior - show network_error_tip, a Retry button, and the underlying error in the login dialog so users and supporters can see what failed - bound the Dart HTTP branch with a 15s timeout; the Rust branch keeps its own bounded per-attempt timeouts and is awaited to completion so a retry never races the URL-keyed ASYNC_HTTP_STATUS entry of an abandoned in-flight request Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(flutter): surface currentUser refresh failures that were only logged Non-transport failures of the token auto-login (/api/currentUser) -- a bad HTTP status, a filter's HTML block page, or an error field in the body -- were only debugPrinted, so the address book / group tabs showed nothing and offered no retry. Reuse the existing networkError channel so netWorkErrorWidget shows the error with its Retry button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(flutter): keep retry row visible with progress while refetching login options Review follow-ups: clicking Retry used to clear the error and hide the row with no pending feedback, which could read as a dead click while the Rust fallback chain runs; keep the row, disable the button, and show the usual LinearProgressIndicator instead. Also raise the Dart HTTP branch timeout to 30s so large web address book pulls on slow links do not newly time out; it still bounds the previously unbounded hang and stays above the Rust side's 12s per-attempt timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: update webpki-roots to latest Mozilla root store 0.26.9 -> 0.26.11 (now a forwarding shim over 1.x, used by tungstenite) 1.0.4 -> 1.0.9 (used by reqwest / hyper-rustls / hbb_common) The 0.26.9 line carried its own root snapshot frozen in early 2025, so the websocket TLS path was building against a stale bundle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: weekly workflow to PR webpki-roots root store updates webpki-roots is a transitive dependency, so dependabot's cargo version updates would not cover it. A scheduled job runs cargo update for every webpki-roots instance in each lockfile and opens a PR when the pinned Mozilla root snapshot is behind, keeping root store changes reviewable instead of baking them silently into release builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(flutter): hide network tip for server-reported currentUser errors Review follow-up: when /api/currentUser fails with an error the server itself reported (an error field in a JSON body, or an unexpected schema), "Please check your network connection" was misleading. Track whether the surfaced error came from a server response and skip the network tip for those; FormatException (a non-JSON body such as a filter's block page) keeps it, since that still indicates a network or middlebox problem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(flutter): close timed-out HTTP clients * fix(flutter): flag server-reported errors at the throw site Review follow-up (CodeRabbit). Classifying by `e is! FormatException` mislabeled ambiguous failures: a middlebox block page returning 200 with valid-but-wrong-shape JSON throws a TypeError from fromJson and was shown without the check-your-network tip, though it is a network artifact. Set networkErrorFromServer only at the one site that is certainly server-reported (an error field in the body); every other failure keeps the network tip plus the raw error text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: serialize webpki-roots update runs, null-delimit lockfile paths Review follow-up (CodeRabbit). A manual dispatch overlapping the weekly cron could have an older run force-push over the newer branch state; queue runs via a concurrency group without cancel-in-progress. Also iterate lockfiles with git ls-files -z so a path with spaces cannot be word-split, and keep the loop failing the step on any cargo error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(flutter): improve login retry feedback Use the theme primary color for the Retry button and hide stale error messages while a retry is in progress. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(flutter): surface login option response errors Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: fufesou <linlong1266@gmail.com>
857 lines
26 KiB
Dart
857 lines
26 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
|
import 'package:flutter_hbb/models/platform_model.dart';
|
|
import 'package:flutter_hbb/models/user_model.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import '../../common.dart';
|
|
import './dialog.dart';
|
|
|
|
const kOpSvgList = [
|
|
'github',
|
|
'gitlab',
|
|
'google',
|
|
'apple',
|
|
'okta',
|
|
'facebook',
|
|
'azure',
|
|
'auth0',
|
|
'microsoft'
|
|
];
|
|
|
|
class _OidcProviderBranding {
|
|
final String label;
|
|
final String iconKey;
|
|
|
|
const _OidcProviderBranding({
|
|
required this.label,
|
|
required this.iconKey,
|
|
});
|
|
}
|
|
|
|
_OidcProviderBranding _oidcProviderBranding(String op) {
|
|
switch (op.toLowerCase()) {
|
|
case 'azure':
|
|
return _OidcProviderBranding(
|
|
label: 'Microsoft',
|
|
iconKey: 'microsoft',
|
|
);
|
|
default:
|
|
return _OidcProviderBranding(
|
|
label: {
|
|
'github': 'GitHub',
|
|
'gitlab': 'GitLab',
|
|
}[op.toLowerCase()] ??
|
|
toCapitalized(op),
|
|
iconKey: op.toLowerCase(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _IconOP extends StatelessWidget {
|
|
final String op;
|
|
final String? icon;
|
|
final EdgeInsets margin;
|
|
const _IconOP(
|
|
{Key? key,
|
|
required this.op,
|
|
required this.icon,
|
|
this.margin = const EdgeInsets.symmetric(horizontal: 4.0)})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final svgFile =
|
|
kOpSvgList.contains(op.toLowerCase()) ? op.toLowerCase() : 'default';
|
|
return Container(
|
|
margin: margin,
|
|
child: icon == null
|
|
? SvgPicture.asset(
|
|
'assets/auth-$svgFile.svg',
|
|
width: 20,
|
|
)
|
|
: SvgPicture.string(
|
|
icon!,
|
|
width: 20,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ButtonOP extends StatelessWidget {
|
|
final String op;
|
|
final RxString curOP;
|
|
final String? icon;
|
|
final Color primaryColor;
|
|
final double height;
|
|
final Function() onTap;
|
|
|
|
const ButtonOP({
|
|
Key? key,
|
|
required this.op,
|
|
required this.curOP,
|
|
required this.icon,
|
|
required this.primaryColor,
|
|
required this.height,
|
|
required this.onTap,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final branding = _oidcProviderBranding(op);
|
|
final buttonLabel = translate("Continue with {${branding.label}}");
|
|
return Row(children: [
|
|
Container(
|
|
height: height,
|
|
width: 200,
|
|
child: Obx(() => ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
|
? primaryColor
|
|
: Colors.grey,
|
|
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
|
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 30,
|
|
child: _IconOP(
|
|
op: branding.iconKey,
|
|
icon: icon,
|
|
margin: EdgeInsets.only(right: 5),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Center(child: Text(buttonLabel)),
|
|
),
|
|
),
|
|
],
|
|
))),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class ConfigOP {
|
|
final String op;
|
|
final String? icon;
|
|
ConfigOP({required this.op, required this.icon});
|
|
}
|
|
|
|
class WidgetOP extends StatefulWidget {
|
|
final ConfigOP config;
|
|
final RxString curOP;
|
|
final Function(Map<String, dynamic>) cbLogin;
|
|
const WidgetOP({
|
|
Key? key,
|
|
required this.config,
|
|
required this.curOP,
|
|
required this.cbLogin,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() {
|
|
return _WidgetOPState();
|
|
}
|
|
}
|
|
|
|
class _WidgetOPState extends State<WidgetOP> {
|
|
Timer? _updateTimer;
|
|
String _stateMsg = '';
|
|
String _failedMsg = '';
|
|
String _url = '';
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_updateTimer?.cancel();
|
|
}
|
|
|
|
_beginQueryState() {
|
|
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
|
_updateState();
|
|
});
|
|
}
|
|
|
|
_updateState() {
|
|
bind.mainAccountAuthResult().then((result) {
|
|
if (result.isEmpty) {
|
|
return;
|
|
}
|
|
final resultMap = jsonDecode(result);
|
|
if (resultMap == null) {
|
|
return;
|
|
}
|
|
final String stateMsg = resultMap['state_msg'];
|
|
String failedMsg = resultMap['failed_msg'];
|
|
final String? url = resultMap['url'];
|
|
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
|
final authBody = resultMap['auth_body'];
|
|
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
|
|
if (_url.isEmpty && url != null && url.isNotEmpty) {
|
|
if (!urlLaunched) {
|
|
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
|
}
|
|
_url = url;
|
|
}
|
|
if (authBody != null) {
|
|
_updateTimer?.cancel();
|
|
widget.curOP.value = '';
|
|
widget.cbLogin(authBody as Map<String, dynamic>);
|
|
}
|
|
|
|
setState(() {
|
|
_stateMsg = stateMsg;
|
|
_failedMsg = failedMsg;
|
|
if (failedMsg.isNotEmpty) {
|
|
widget.curOP.value = '';
|
|
_updateTimer?.cancel();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
_resetState() {
|
|
_stateMsg = '';
|
|
_failedMsg = '';
|
|
_url = '';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
ButtonOP(
|
|
op: widget.config.op,
|
|
curOP: widget.curOP,
|
|
icon: widget.config.icon,
|
|
primaryColor: str2color(widget.config.op, 0x7f),
|
|
height: 36,
|
|
onTap: () async {
|
|
_resetState();
|
|
widget.curOP.value = widget.config.op;
|
|
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
|
_beginQueryState();
|
|
},
|
|
),
|
|
Obx(() {
|
|
if (widget.curOP.isNotEmpty &&
|
|
widget.curOP.value != widget.config.op) {
|
|
_failedMsg = '';
|
|
}
|
|
return Offstage(
|
|
offstage:
|
|
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: SelectableText(
|
|
translate(_stateMsg),
|
|
style: DefaultTextStyle.of(context)
|
|
.style
|
|
.copyWith(fontSize: 12),
|
|
),
|
|
),
|
|
if (_failedMsg.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Builder(builder: (context) {
|
|
final errorColor = Theme.of(context).colorScheme.error;
|
|
final bgColor = Theme.of(context)
|
|
.colorScheme
|
|
.errorContainer
|
|
.withOpacity(0.3);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8.0, vertical: 6.0),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(4.0),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.error_outline,
|
|
color: errorColor, size: 16),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: SelectableText(
|
|
translate(_failedMsg),
|
|
style:
|
|
DefaultTextStyle.of(context).style.copyWith(
|
|
fontSize: 13,
|
|
color: errorColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
Obx(
|
|
() => Offstage(
|
|
offstage: widget.curOP.value != widget.config.op,
|
|
child: const SizedBox(
|
|
height: 5.0,
|
|
),
|
|
),
|
|
),
|
|
Obx(
|
|
() => Offstage(
|
|
offstage: widget.curOP.value != widget.config.op,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxHeight: 20),
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
widget.curOP.value = '';
|
|
_updateTimer?.cancel();
|
|
_resetState();
|
|
bind.mainAccountAuthCancel();
|
|
},
|
|
child: Text(
|
|
translate('Cancel'),
|
|
style: TextStyle(fontSize: 15),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class LoginWidgetOP extends StatelessWidget {
|
|
final List<ConfigOP> ops;
|
|
final RxString curOP;
|
|
final Function(Map<String, dynamic>) cbLogin;
|
|
|
|
LoginWidgetOP({
|
|
Key? key,
|
|
required this.ops,
|
|
required this.curOP,
|
|
required this.cbLogin,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var children = ops
|
|
.map((op) => [
|
|
WidgetOP(
|
|
config: op,
|
|
curOP: curOP,
|
|
cbLogin: cbLogin,
|
|
),
|
|
const Divider(
|
|
indent: 5,
|
|
endIndent: 5,
|
|
)
|
|
])
|
|
.expand((i) => i)
|
|
.toList();
|
|
if (children.isNotEmpty) {
|
|
children.removeLast();
|
|
}
|
|
return SingleChildScrollView(
|
|
child: Container(
|
|
width: 200,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: children,
|
|
)));
|
|
}
|
|
}
|
|
|
|
class LoginWidgetUserPass extends StatelessWidget {
|
|
final TextEditingController username;
|
|
final TextEditingController pass;
|
|
final String? usernameMsg;
|
|
final String? passMsg;
|
|
final bool isInProgress;
|
|
final RxString curOP;
|
|
final Function() onLogin;
|
|
final FocusNode? userFocusNode;
|
|
const LoginWidgetUserPass({
|
|
Key? key,
|
|
this.userFocusNode,
|
|
required this.username,
|
|
required this.pass,
|
|
required this.usernameMsg,
|
|
required this.passMsg,
|
|
required this.isInProgress,
|
|
required this.curOP,
|
|
required this.onLogin,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.all(0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(height: 8.0),
|
|
DialogTextField(
|
|
title: translate(DialogTextField.kUsernameTitle),
|
|
controller: username,
|
|
focusNode: userFocusNode,
|
|
prefixIcon: DialogTextField.kUsernameIcon,
|
|
errorText: usernameMsg),
|
|
PasswordWidget(
|
|
controller: pass,
|
|
autoFocus: false,
|
|
reRequestFocus: true,
|
|
errorText: passMsg,
|
|
),
|
|
// NOT use Offstage to wrap LinearProgressIndicator
|
|
if (isInProgress) const LinearProgressIndicator(),
|
|
const SizedBox(height: 12.0),
|
|
FittedBox(
|
|
child:
|
|
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
Container(
|
|
height: 38,
|
|
width: 200,
|
|
child: Obx(() => ElevatedButton(
|
|
child: Text(
|
|
translate('Login'),
|
|
style: TextStyle(fontSize: 16),
|
|
),
|
|
onPressed:
|
|
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
|
? () {
|
|
onLogin();
|
|
}
|
|
: null,
|
|
)),
|
|
),
|
|
])),
|
|
],
|
|
));
|
|
}
|
|
}
|
|
|
|
const kAuthReqTypeOidc = 'oidc/';
|
|
|
|
// call this directly
|
|
Future<bool?> loginDialog() async {
|
|
var username =
|
|
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
|
|
var password = TextEditingController();
|
|
final userFocusNode = FocusNode()..requestFocus();
|
|
Timer(Duration(milliseconds: 100), () => userFocusNode..requestFocus());
|
|
|
|
String? usernameMsg;
|
|
String? passwordMsg;
|
|
var isInProgress = false;
|
|
final RxString curOP = ''.obs;
|
|
// Track hover state for the close icon
|
|
bool isCloseHovered = false;
|
|
|
|
final loginOptions = [].obs;
|
|
final loginOptionsError = Rxn<String>();
|
|
final loginOptionsInProgress = false.obs;
|
|
fetchLoginOptions() async {
|
|
loginOptionsInProgress.value = true;
|
|
try {
|
|
loginOptions.value = await UserModel.queryOidcLoginOptions();
|
|
loginOptionsError.value = null;
|
|
} catch (e) {
|
|
debugPrint("queryOidcLoginOptions failed: $e");
|
|
loginOptionsError.value = e.toString();
|
|
} finally {
|
|
loginOptionsInProgress.value = false;
|
|
}
|
|
}
|
|
|
|
Future.delayed(Duration.zero, fetchLoginOptions);
|
|
|
|
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
|
username.addListener(() {
|
|
if (usernameMsg != null) {
|
|
setState(() => usernameMsg = null);
|
|
}
|
|
});
|
|
|
|
password.addListener(() {
|
|
if (passwordMsg != null) {
|
|
setState(() => passwordMsg = null);
|
|
}
|
|
});
|
|
|
|
onDialogCancel() {
|
|
isInProgress = false;
|
|
close(false);
|
|
}
|
|
|
|
handleLoginResponse(LoginResponse resp, bool storeIfAccessToken,
|
|
void Function([dynamic])? close) async {
|
|
switch (resp.type) {
|
|
case HttpType.kAuthResTypeToken:
|
|
if (resp.access_token != null) {
|
|
if (storeIfAccessToken) {
|
|
await bind.mainSetLocalOption(
|
|
key: 'access_token', value: resp.access_token!);
|
|
await bind.mainSetLocalOption(
|
|
key: 'user_info', value: jsonEncode(resp.user ?? {}));
|
|
}
|
|
if (close != null) {
|
|
close(true);
|
|
}
|
|
return;
|
|
}
|
|
break;
|
|
case HttpType.kAuthResTypeEmailCheck:
|
|
bool? isEmailVerification;
|
|
if (resp.tfa_type == null ||
|
|
resp.tfa_type == HttpType.kAuthResTypeEmailCheck) {
|
|
isEmailVerification = true;
|
|
} else if (resp.tfa_type == HttpType.kAuthResTypeTfaCheck) {
|
|
isEmailVerification = false;
|
|
} else {
|
|
passwordMsg = "Failed, bad tfa type from server";
|
|
}
|
|
if (isEmailVerification != null) {
|
|
if (isMobile) {
|
|
if (close != null) close(null);
|
|
verificationCodeDialog(
|
|
resp.user, resp.secret, isEmailVerification);
|
|
} else {
|
|
setState(() => isInProgress = false);
|
|
// Workaround for web, close the dialog first, then show the verification code dialog.
|
|
// Otherwise, the text field will keep selecting the text and we can't input the code.
|
|
// Not sure why this happens.
|
|
if (isWeb && close != null) close(null);
|
|
final res = await verificationCodeDialog(
|
|
resp.user, resp.secret, isEmailVerification);
|
|
if (res == true) {
|
|
if (!isWeb && close != null) close(false);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
passwordMsg = "Failed, bad response from server";
|
|
break;
|
|
}
|
|
}
|
|
|
|
onLogin() async {
|
|
// validate
|
|
if (username.text.isEmpty) {
|
|
setState(() => usernameMsg = translate('Username missed'));
|
|
return;
|
|
}
|
|
if (password.text.isEmpty) {
|
|
setState(() => passwordMsg = translate('Password missed'));
|
|
return;
|
|
}
|
|
curOP.value = 'rustdesk';
|
|
setState(() => isInProgress = true);
|
|
try {
|
|
final resp = await gFFI.userModel.login(LoginRequest(
|
|
username: username.text,
|
|
password: password.text,
|
|
id: await bind.mainGetMyId(),
|
|
uuid: await bind.mainGetUuid(),
|
|
autoLogin: true,
|
|
type: HttpType.kAuthReqTypeAccount));
|
|
await handleLoginResponse(resp, true, close);
|
|
} on RequestException catch (err) {
|
|
passwordMsg = translate(err.cause);
|
|
} catch (err) {
|
|
passwordMsg = "Unknown Error: $err";
|
|
}
|
|
curOP.value = '';
|
|
setState(() => isInProgress = false);
|
|
}
|
|
|
|
thirdAuthWidget() => Obx(() {
|
|
final error = loginOptionsError.value;
|
|
final inProgress = loginOptionsInProgress.value;
|
|
if (error != null) {
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 8.0),
|
|
// NOT use Offstage to wrap LinearProgressIndicator
|
|
if (inProgress) const LinearProgressIndicator(),
|
|
if (!inProgress)
|
|
Text(
|
|
translate('network_error_tip'),
|
|
style: const TextStyle(fontSize: 12),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.primary,
|
|
),
|
|
onPressed: inProgress ? null : fetchLoginOptions,
|
|
child: Text(translate('Retry')),
|
|
),
|
|
if (!inProgress)
|
|
SelectableText(
|
|
error,
|
|
style: const TextStyle(fontSize: 11, color: Colors.red),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Offstage(
|
|
offstage: loginOptions.isEmpty,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(
|
|
height: 8.0,
|
|
),
|
|
Center(
|
|
child: Text(
|
|
translate('or'),
|
|
style: TextStyle(fontSize: 16),
|
|
)),
|
|
const SizedBox(
|
|
height: 8.0,
|
|
),
|
|
LoginWidgetOP(
|
|
ops: loginOptions
|
|
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
|
|
.toList(),
|
|
curOP: curOP,
|
|
cbLogin: (Map<String, dynamic> authBody) async {
|
|
LoginResponse? resp;
|
|
try {
|
|
// access_token is already stored in the rust side.
|
|
resp =
|
|
gFFI.userModel.getLoginResponseFromAuthBody(authBody);
|
|
} catch (e) {
|
|
debugPrint(
|
|
'Failed to parse oidc login body: "$authBody"');
|
|
}
|
|
close(true);
|
|
|
|
if (resp != null) {
|
|
handleLoginResponse(resp, false, null);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
|
|
final title = Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
translate('Login'),
|
|
).marginOnly(top: MyTheme.dialogPadding),
|
|
MouseRegion(
|
|
onEnter: (_) => setState(() => isCloseHovered = true),
|
|
onExit: (_) => setState(() => isCloseHovered = false),
|
|
child: InkWell(
|
|
child: Icon(
|
|
Icons.close,
|
|
size: 25,
|
|
// No need to handle the branch of null.
|
|
// Because we can ensure the color is not null when debug.
|
|
color: isCloseHovered
|
|
? Colors.white
|
|
: Theme.of(context)
|
|
.textTheme
|
|
.titleLarge
|
|
?.color
|
|
?.withOpacity(0.55),
|
|
),
|
|
onTap: onDialogCancel,
|
|
hoverColor: Colors.red,
|
|
borderRadius: BorderRadius.circular(5),
|
|
),
|
|
).marginOnly(top: 10, right: 15),
|
|
],
|
|
);
|
|
final titlePadding = EdgeInsets.fromLTRB(MyTheme.dialogPadding, 0, 0, 0);
|
|
|
|
return CustomAlertDialog(
|
|
title: title,
|
|
titlePadding: titlePadding,
|
|
contentBoxConstraints: BoxConstraints(minWidth: 400),
|
|
content: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(
|
|
height: 8.0,
|
|
),
|
|
LoginWidgetUserPass(
|
|
username: username,
|
|
pass: password,
|
|
usernameMsg: usernameMsg,
|
|
passMsg: passwordMsg,
|
|
isInProgress: isInProgress,
|
|
curOP: curOP,
|
|
onLogin: onLogin,
|
|
userFocusNode: userFocusNode,
|
|
),
|
|
thirdAuthWidget(),
|
|
],
|
|
),
|
|
onCancel: onDialogCancel,
|
|
onSubmit: onLogin,
|
|
);
|
|
});
|
|
|
|
if (res != null) {
|
|
await UserModel.updateOtherModels();
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
Future<bool?> verificationCodeDialog(
|
|
UserPayload? user, String? secret, bool isEmailVerification) async {
|
|
var autoLogin = true;
|
|
var isInProgress = false;
|
|
String? errorText;
|
|
|
|
final code = TextEditingController();
|
|
|
|
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
|
|
void onVerify() async {
|
|
setState(() => isInProgress = true);
|
|
|
|
try {
|
|
final resp = await gFFI.userModel.login(LoginRequest(
|
|
verificationCode: code.text,
|
|
tfaCode: isEmailVerification ? null : code.text,
|
|
secret: secret,
|
|
username: user?.name,
|
|
id: await bind.mainGetMyId(),
|
|
uuid: await bind.mainGetUuid(),
|
|
autoLogin: autoLogin,
|
|
type: HttpType.kAuthReqTypeEmailCode));
|
|
|
|
switch (resp.type) {
|
|
case HttpType.kAuthResTypeToken:
|
|
if (resp.access_token != null) {
|
|
await bind.mainSetLocalOption(
|
|
key: 'access_token', value: resp.access_token!);
|
|
close(true);
|
|
return;
|
|
}
|
|
break;
|
|
default:
|
|
errorText = "Failed, bad response from server";
|
|
break;
|
|
}
|
|
} on RequestException catch (err) {
|
|
errorText = translate(err.cause);
|
|
} catch (err) {
|
|
errorText = "Unknown Error: $err";
|
|
}
|
|
|
|
setState(() => isInProgress = false);
|
|
}
|
|
|
|
final codeField = isEmailVerification
|
|
? DialogEmailCodeField(
|
|
controller: code,
|
|
errorText: errorText,
|
|
readyCallback: onVerify,
|
|
onChanged: () => errorText = null,
|
|
)
|
|
: Dialog2FaField(
|
|
controller: code,
|
|
errorText: errorText,
|
|
readyCallback: onVerify,
|
|
onChanged: () => errorText = null,
|
|
);
|
|
|
|
getOnSubmit() => codeField.isReady ? onVerify : null;
|
|
|
|
return CustomAlertDialog(
|
|
title: Text(translate("Verification code")),
|
|
contentBoxConstraints: BoxConstraints(maxWidth: 300),
|
|
content: Column(
|
|
children: [
|
|
Offstage(
|
|
offstage: !isEmailVerification || user?.email == null,
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
labelText: "Email", prefixIcon: Icon(Icons.email)),
|
|
readOnly: true,
|
|
controller: TextEditingController(text: user?.email),
|
|
).workaroundFreezeLinuxMint()),
|
|
isEmailVerification ? const SizedBox(height: 8) : const Offstage(),
|
|
codeField,
|
|
/*
|
|
CheckboxListTile(
|
|
contentPadding: const EdgeInsets.all(0),
|
|
dense: true,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
title: Row(children: [
|
|
Expanded(child: Text(translate("Trust this device")))
|
|
]),
|
|
value: trustThisDevice,
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
setState(() => trustThisDevice = !trustThisDevice);
|
|
},
|
|
),
|
|
*/
|
|
// NOT use Offstage to wrap LinearProgressIndicator
|
|
if (isInProgress) const LinearProgressIndicator(),
|
|
],
|
|
),
|
|
onCancel: close,
|
|
onSubmit: getOnSubmit(),
|
|
actions: [
|
|
dialogButton("Cancel", onPressed: close, isOutline: true),
|
|
dialogButton("Verify", onPressed: getOnSubmit()),
|
|
]);
|
|
});
|
|
// For verification code, desktop update other models in login dialog, mobile need to close login dialog first,
|
|
// otherwise the soft keyboard will jump out on each key press, so mobile update in verification code dialog.
|
|
if (isMobile && res == true) {
|
|
await UserModel.updateOtherModels();
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
void logOutConfirmDialog() {
|
|
gFFI.dialogManager.show((setState, close, context) {
|
|
submit() {
|
|
close();
|
|
gFFI.userModel.logOut();
|
|
}
|
|
|
|
return CustomAlertDialog(
|
|
content: Text(translate("logout_tip")),
|
|
actions: [
|
|
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
|
|
dialogButton(translate("OK"), onPressed: submit),
|
|
],
|
|
onSubmit: submit,
|
|
onCancel: close,
|
|
);
|
|
});
|
|
}
|