mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-17 01:41:02 +03:00
* refact(oidc): manually open the browser Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): allow copying OIDC authentication links Signed-off-by: fufesou <linlong1266@gmail.com> * Remove unused translation in ko.rs Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): better hint on browser didn't open Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): login handle exception Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): remove unused translations Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): login handle error Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): login in flight Signed-off-by: fufesou <linlong1266@gmail.com> * refact(translation): move "Continue" to the end of template.rs Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): var rename Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): remove useless "open sign-in page" Signed-off-by: fufesou <linlong1266@gmail.com> * Remove unecessary translation contents Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): better way to show&expand the url Signed-off-by: fufesou <linlong1266@gmail.com> * refact(oidc): better login ui Signed-off-by: fufesou <linlong1266@gmail.com> * fix(oidc): discard stale auth results after cancellation Signed-off-by: fufesou <linlong1266@gmail.com> * fix(oidc): handle auth status query failures safely Signed-off-by: fufesou <linlong1266@gmail.com> * fix(oidc): prevent concurrent login operations - reuse the active login dialog and block duplicate password submissions - cancel only active OIDC operations when closing the dialog - preserve authentication state until failure cancellation succeeds Signed-off-by: fufesou <linlong1266@gmail.com> * fix(oidc): refine login options error feedback Preserve typed errors to hide the network tip for HTTP failures and clarify the login-options API contract. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
1132 lines
34 KiB
Dart
1132 lines
34 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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';
|
|
import './oidc_auth_status.dart';
|
|
|
|
const kOpSvgList = [
|
|
'github',
|
|
'gitlab',
|
|
'google',
|
|
'apple',
|
|
'okta',
|
|
'facebook',
|
|
'azure',
|
|
'auth0',
|
|
'microsoft'
|
|
];
|
|
const _requestingAccountAuth = 'Requesting account auth';
|
|
const _waitingAccountAuth = 'Waiting account auth';
|
|
|
|
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;
|
|
final bool Function() canStartAuth;
|
|
|
|
const ButtonOP({
|
|
Key? key,
|
|
required this.op,
|
|
required this.curOP,
|
|
required this.icon,
|
|
required this.primaryColor,
|
|
required this.height,
|
|
required this.onTap,
|
|
required this.canStartAuth,
|
|
}) : 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: primaryColor,
|
|
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
|
onPressed:
|
|
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
|
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 _OidcAuthController {
|
|
final RxString curOP = ''.obs;
|
|
Future<void> _pendingOperation = Future<void>.value();
|
|
int _authAttempt = 0;
|
|
bool _closed = false;
|
|
final _cancelInProgress = false.obs;
|
|
|
|
bool _isCurrent(int authAttempt, String op) {
|
|
return !_closed && authAttempt == _authAttempt && curOP.value == op;
|
|
}
|
|
|
|
Future<bool> start(String op) {
|
|
if (!canStart()) {
|
|
return Future<bool>.value(false);
|
|
}
|
|
final authAttempt = ++_authAttempt;
|
|
curOP.value = op;
|
|
// Web auth must start during the original user gesture so popups are allowed.
|
|
if (isWeb) {
|
|
return _startWeb(authAttempt, op);
|
|
}
|
|
final completer = Completer<bool>();
|
|
_pendingOperation = _pendingOperation.then((_) async {
|
|
if (!_isCurrent(authAttempt, op)) {
|
|
completer.complete(false);
|
|
return;
|
|
}
|
|
try {
|
|
await bind.mainAccountAuthCancel();
|
|
if (!_isCurrent(authAttempt, op)) {
|
|
completer.complete(false);
|
|
return;
|
|
}
|
|
await bind.mainAccountAuth(op: op, rememberMe: true);
|
|
completer.complete(_isCurrent(authAttempt, op));
|
|
} catch (error, stackTrace) {
|
|
completer.completeError(error, stackTrace);
|
|
}
|
|
});
|
|
return completer.future;
|
|
}
|
|
|
|
Future<bool> _startWeb(int authAttempt, String op) async {
|
|
await bind.mainAccountAuth(op: op, rememberMe: true);
|
|
return _isCurrent(authAttempt, op);
|
|
}
|
|
|
|
bool canStart() {
|
|
return !_closed && !_cancelInProgress.value;
|
|
}
|
|
|
|
Future<bool> cancelCurrent(String op) {
|
|
if (!canStart() || curOP.value != op) {
|
|
return Future<bool>.value(false);
|
|
}
|
|
final authAttempt = ++_authAttempt;
|
|
final completer = Completer<bool>();
|
|
_cancelInProgress.value = true;
|
|
_pendingOperation = _pendingOperation.then((_) async {
|
|
try {
|
|
await bind.mainAccountAuthCancel();
|
|
completer.complete(_isCurrent(authAttempt, op));
|
|
} catch (error, stackTrace) {
|
|
completer.completeError(error, stackTrace);
|
|
} finally {
|
|
_cancelInProgress.value = false;
|
|
}
|
|
});
|
|
return completer.future;
|
|
}
|
|
|
|
Future<void> _cancelBackend() async {
|
|
try {
|
|
await bind.mainAccountAuthCancel();
|
|
} catch (error, stackTrace) {
|
|
debugPrint('Failed to cancel account authentication $error');
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
}
|
|
}
|
|
|
|
Future<void> close() async {
|
|
if (_closed) {
|
|
return;
|
|
}
|
|
final hasActiveOidcAuth =
|
|
curOP.value.isNotEmpty && curOP.value != 'rustdesk';
|
|
_closed = true;
|
|
_authAttempt++;
|
|
curOP.value = '';
|
|
if (hasActiveOidcAuth) {
|
|
await _cancelBackend();
|
|
}
|
|
await _pendingOperation;
|
|
if (hasActiveOidcAuth) {
|
|
await _cancelBackend();
|
|
}
|
|
}
|
|
}
|
|
|
|
class WidgetOP extends StatefulWidget {
|
|
final ConfigOP config;
|
|
final RxString curOP;
|
|
final Function(Map<String, dynamic>) cbLogin;
|
|
final Future<bool> Function(String) startAuth;
|
|
final Future<bool> Function(String) cancelAuth;
|
|
final bool Function() canStartAuth;
|
|
const WidgetOP({
|
|
Key? key,
|
|
required this.config,
|
|
required this.curOP,
|
|
required this.cbLogin,
|
|
required this.startAuth,
|
|
required this.cancelAuth,
|
|
required this.canStartAuth,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() {
|
|
return _WidgetOPState();
|
|
}
|
|
}
|
|
|
|
class _WidgetOPState extends State<WidgetOP> {
|
|
Timer? _updateTimer;
|
|
bool _isAuthStatusQueryInFlight = false;
|
|
int _authAttempt = 0;
|
|
String _stateMsg = '';
|
|
String _failedMsg = '';
|
|
String _url = '';
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_updateTimer?.cancel();
|
|
}
|
|
|
|
_beginQueryState(int authAttempt) {
|
|
_updateTimer?.cancel();
|
|
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
|
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
|
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
|
});
|
|
}
|
|
|
|
Future<void> _runAuthStatusQuery(Future<void> Function() query) async {
|
|
if (_isAuthStatusQueryInFlight) {
|
|
return;
|
|
}
|
|
_isAuthStatusQueryInFlight = true;
|
|
try {
|
|
await query();
|
|
} finally {
|
|
_isAuthStatusQueryInFlight = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _launchAuthUrl(String url) async {
|
|
try {
|
|
final launched = await launchUrl(
|
|
Uri.parse(url),
|
|
mode: LaunchMode.externalApplication,
|
|
);
|
|
if (!launched) {
|
|
debugPrint('Failed to open OIDC authentication URL');
|
|
}
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'Failed to open OIDC authentication URL (${error.runtimeType})');
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
}
|
|
}
|
|
|
|
Future<void> _copyAuthUrl(String url) async {
|
|
try {
|
|
await Clipboard.setData(ClipboardData(text: url));
|
|
showToast(
|
|
translate('Copied'),
|
|
);
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'Failed to copy OIDC authentication URL (${error.runtimeType})');
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
showToast(translate('Failed'));
|
|
}
|
|
}
|
|
|
|
void _runCurrentAuthUrlAction(
|
|
int authAttempt,
|
|
String authUrl,
|
|
Future<void> Function(String) action,
|
|
) {
|
|
if (!mounted ||
|
|
authAttempt != _authAttempt ||
|
|
widget.curOP.value != widget.config.op ||
|
|
authUrl.isEmpty ||
|
|
_url != authUrl) {
|
|
return;
|
|
}
|
|
unawaited(action(authUrl));
|
|
}
|
|
|
|
void _invalidateAuthAttempt() {
|
|
_authAttempt++;
|
|
_url = '';
|
|
}
|
|
|
|
bool _isCurrentAuthAttempt(int authAttempt) {
|
|
return mounted &&
|
|
authAttempt == _authAttempt &&
|
|
widget.curOP.value == widget.config.op;
|
|
}
|
|
|
|
Future<void> _handleAuthFailure(
|
|
int authAttempt,
|
|
Object error,
|
|
String operation,
|
|
) async {
|
|
debugPrint('Failed to $operation $error');
|
|
if (!_isCurrentAuthAttempt(authAttempt)) {
|
|
return;
|
|
}
|
|
_updateTimer?.cancel();
|
|
setState(() => _failedMsg = 'Failed');
|
|
try {
|
|
final canceled = await widget.cancelAuth(widget.config.op);
|
|
if (!canceled || !_isCurrentAuthAttempt(authAttempt)) {
|
|
return;
|
|
}
|
|
} catch (cancelError, stackTrace) {
|
|
debugPrint('Failed to cancel account authentication $cancelError');
|
|
debugPrintStack(stackTrace: stackTrace);
|
|
return;
|
|
}
|
|
setState(() {
|
|
_invalidateAuthAttempt();
|
|
widget.curOP.value = '';
|
|
});
|
|
}
|
|
|
|
Future<void> _updateState(int authAttempt) {
|
|
if (!mounted ||
|
|
authAttempt != _authAttempt ||
|
|
widget.curOP.value != widget.config.op) {
|
|
_updateTimer?.cancel();
|
|
return Future<void>.value();
|
|
}
|
|
return bind.mainAccountAuthResult().then<void>((result) {
|
|
if (!mounted ||
|
|
authAttempt != _authAttempt ||
|
|
widget.curOP.value != widget.config.op ||
|
|
result.isEmpty) {
|
|
return;
|
|
}
|
|
final resultMap = jsonDecode(result);
|
|
if (resultMap == null) {
|
|
return;
|
|
}
|
|
final String backendStateMsg = resultMap['state_msg'];
|
|
String failedMsg = resultMap['failed_msg'];
|
|
final String? url = resultMap['url'];
|
|
final stateMsg = backendStateMsg == _requestingAccountAuth &&
|
|
(url == null || url.isEmpty)
|
|
? _waitingAccountAuth
|
|
: backendStateMsg;
|
|
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
|
|
final authBody = resultMap['auth_body'];
|
|
if (authBody != null) {
|
|
_updateTimer?.cancel();
|
|
_invalidateAuthAttempt();
|
|
widget.curOP.value = '';
|
|
widget.cbLogin(authBody as Map<String, dynamic>);
|
|
return;
|
|
}
|
|
final stateChanged = _stateMsg != stateMsg || _failedMsg != failedMsg;
|
|
final newUrl = _url.isEmpty && url != null && url.isNotEmpty ? url : null;
|
|
if (!stateChanged && newUrl == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_stateMsg = stateMsg;
|
|
_failedMsg = failedMsg;
|
|
if (newUrl != null) {
|
|
_url = newUrl;
|
|
}
|
|
if (failedMsg.isNotEmpty) {
|
|
_invalidateAuthAttempt();
|
|
widget.curOP.value = '';
|
|
_updateTimer?.cancel();
|
|
}
|
|
});
|
|
if (newUrl != null && failedMsg.isEmpty && !urlLaunched) {
|
|
unawaited(_launchAuthUrl(newUrl));
|
|
}
|
|
}).catchError(
|
|
(e) => _handleAuthFailure(
|
|
authAttempt,
|
|
e,
|
|
'query account authentication',
|
|
),
|
|
);
|
|
}
|
|
|
|
int _resetState() {
|
|
_updateTimer?.cancel();
|
|
setState(() {
|
|
_invalidateAuthAttempt();
|
|
_stateMsg = _waitingAccountAuth;
|
|
_failedMsg = '';
|
|
});
|
|
return _authAttempt;
|
|
}
|
|
|
|
@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,
|
|
canStartAuth: widget.canStartAuth,
|
|
onTap: () async {
|
|
if (!widget.canStartAuth()) {
|
|
return;
|
|
}
|
|
final authAttempt = _resetState();
|
|
try {
|
|
final started = await widget.startAuth(widget.config.op);
|
|
if (!started) {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
await _handleAuthFailure(
|
|
authAttempt,
|
|
e,
|
|
'start account authentication',
|
|
);
|
|
return;
|
|
}
|
|
if (!mounted ||
|
|
authAttempt != _authAttempt ||
|
|
widget.curOP.value != widget.config.op) {
|
|
return;
|
|
}
|
|
_beginQueryState(authAttempt);
|
|
},
|
|
),
|
|
Obx(() {
|
|
if (widget.curOP.isNotEmpty &&
|
|
widget.curOP.value != widget.config.op) {
|
|
_failedMsg = '';
|
|
}
|
|
final authAttempt = _authAttempt;
|
|
final authUrl = _url;
|
|
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: OidcAuthStatus(
|
|
message: translate(_stateMsg),
|
|
browserFallbackPrompt: translate(
|
|
"Browser didn't open? Use the url below to sign in.",
|
|
),
|
|
authUrl: authUrl,
|
|
copyLabel: translate('Copy to clipboard'),
|
|
onCopy: authUrl.isEmpty
|
|
? null
|
|
: () => _runCurrentAuthUrlAction(
|
|
authAttempt,
|
|
authUrl,
|
|
_copyAuthUrl,
|
|
),
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class LoginWidgetOP extends StatelessWidget {
|
|
final List<ConfigOP> ops;
|
|
final RxString curOP;
|
|
final Function(Map<String, dynamic>) cbLogin;
|
|
final Future<bool> Function(String) startAuth;
|
|
final Future<bool> Function(String) cancelAuth;
|
|
final bool Function() canStartAuth;
|
|
|
|
LoginWidgetOP({
|
|
Key? key,
|
|
required this.ops,
|
|
required this.curOP,
|
|
required this.cbLogin,
|
|
required this.startAuth,
|
|
required this.cancelAuth,
|
|
required this.canStartAuth,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var children = ops
|
|
.map((op) => [
|
|
WidgetOP(
|
|
config: op,
|
|
curOP: curOP,
|
|
cbLogin: cbLogin,
|
|
startAuth: startAuth,
|
|
cancelAuth: cancelAuth,
|
|
canStartAuth: canStartAuth,
|
|
),
|
|
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 && !isInProgress
|
|
? () {
|
|
onLogin();
|
|
}
|
|
: null,
|
|
)),
|
|
),
|
|
])),
|
|
],
|
|
));
|
|
}
|
|
}
|
|
|
|
const kAuthReqTypeOidc = 'oidc/';
|
|
|
|
Future<bool?>? _activeLoginDialog;
|
|
|
|
// call this directly
|
|
Future<bool?> loginDialog() {
|
|
final activeDialog = _activeLoginDialog;
|
|
if (activeDialog != null) {
|
|
return activeDialog;
|
|
}
|
|
final dialog = _openLoginDialogOnce();
|
|
_activeLoginDialog = dialog;
|
|
return dialog;
|
|
}
|
|
|
|
Future<bool?> _openLoginDialogOnce() async {
|
|
try {
|
|
return await _openLoginDialog();
|
|
} finally {
|
|
_activeLoginDialog = null;
|
|
}
|
|
}
|
|
|
|
Future<bool?> _openLoginDialog() 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 oidcAuth = _OidcAuthController();
|
|
final curOP = oidcAuth.curOP;
|
|
// Track hover state for the close icon
|
|
bool isCloseHovered = false;
|
|
|
|
final loginOptions = [].obs;
|
|
final loginOptionsError = Rxn<Object>();
|
|
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;
|
|
} 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 {
|
|
if (curOP.value.isNotEmpty || isInProgress) {
|
|
return;
|
|
}
|
|
// 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 && error is! RequestException)
|
|
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.toString(),
|
|
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,
|
|
startAuth: oidcAuth.start,
|
|
cancelAuth: oidcAuth.cancelCurrent,
|
|
canStartAuth: oidcAuth.canStart,
|
|
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,
|
|
);
|
|
}).whenComplete(oidcAuth.close);
|
|
|
|
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,
|
|
);
|
|
});
|
|
}
|