mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-09 05:51:00 +03:00
refact(oidc): manually open the browser (#15706)
* 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>
This commit is contained in:
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -11,6 +12,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './dialog.dart';
|
||||
import './oidc_auth_status.dart';
|
||||
|
||||
const kOpSvgList = [
|
||||
'github',
|
||||
@@ -23,6 +25,8 @@ const kOpSvgList = [
|
||||
'auth0',
|
||||
'microsoft'
|
||||
];
|
||||
const _requestingAccountAuth = 'Requesting account auth';
|
||||
const _waitingAccountAuth = 'Waiting account auth';
|
||||
|
||||
class _OidcProviderBranding {
|
||||
final String label;
|
||||
@@ -90,6 +94,7 @@ class ButtonOP extends StatelessWidget {
|
||||
final Color primaryColor;
|
||||
final double height;
|
||||
final Function() onTap;
|
||||
final bool Function() canStartAuth;
|
||||
|
||||
const ButtonOP({
|
||||
Key? key,
|
||||
@@ -99,6 +104,7 @@ class ButtonOP extends StatelessWidget {
|
||||
required this.primaryColor,
|
||||
required this.height,
|
||||
required this.onTap,
|
||||
required this.canStartAuth,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -111,11 +117,10 @@ class ButtonOP extends StatelessWidget {
|
||||
width: 200,
|
||||
child: Obx(() => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: curOP.value.isEmpty || curOP.value == op
|
||||
? primaryColor
|
||||
: Colors.grey,
|
||||
backgroundColor: primaryColor,
|
||||
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
|
||||
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
|
||||
onPressed:
|
||||
curOP.value == 'rustdesk' || !canStartAuth() ? null : onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
@@ -145,15 +150,120 @@ class ConfigOP {
|
||||
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
|
||||
@@ -164,6 +274,8 @@ class WidgetOP extends StatefulWidget {
|
||||
|
||||
class _WidgetOPState extends State<WidgetOP> {
|
||||
Timer? _updateTimer;
|
||||
bool _isAuthStatusQueryInFlight = false;
|
||||
int _authAttempt = 0;
|
||||
String _stateMsg = '';
|
||||
String _failedMsg = '';
|
||||
String _url = '';
|
||||
@@ -174,55 +286,180 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
_updateTimer?.cancel();
|
||||
}
|
||||
|
||||
_beginQueryState() {
|
||||
_beginQueryState(int authAttempt) {
|
||||
_updateTimer?.cancel();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
|
||||
_updateState();
|
||||
unawaited(_runAuthStatusQuery(() => _updateState(authAttempt)));
|
||||
});
|
||||
}
|
||||
|
||||
_updateState() {
|
||||
bind.mainAccountAuthResult().then((result) {
|
||||
if (result.isEmpty) {
|
||||
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 stateMsg = resultMap['state_msg'];
|
||||
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 (_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();
|
||||
}
|
||||
});
|
||||
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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
_stateMsg = '';
|
||||
_failedMsg = '';
|
||||
_url = '';
|
||||
int _resetState() {
|
||||
_updateTimer?.cancel();
|
||||
setState(() {
|
||||
_invalidateAuthAttempt();
|
||||
_stateMsg = _waitingAccountAuth;
|
||||
_failedMsg = '';
|
||||
});
|
||||
return _authAttempt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -235,11 +472,31 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
icon: widget.config.icon,
|
||||
primaryColor: str2color(widget.config.op, 0x7f),
|
||||
height: 36,
|
||||
canStartAuth: widget.canStartAuth,
|
||||
onTap: () async {
|
||||
_resetState();
|
||||
widget.curOP.value = widget.config.op;
|
||||
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
|
||||
_beginQueryState();
|
||||
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(() {
|
||||
@@ -247,6 +504,8 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
widget.curOP.value != widget.config.op) {
|
||||
_failedMsg = '';
|
||||
}
|
||||
final authAttempt = _authAttempt;
|
||||
final authUrl = _url;
|
||||
return Offstage(
|
||||
offstage:
|
||||
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
|
||||
@@ -256,11 +515,20 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
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),
|
||||
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)
|
||||
@@ -304,34 +572,6 @@ class _WidgetOPState extends State<WidgetOP> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -341,12 +581,18 @@ 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
|
||||
@@ -357,6 +603,9 @@ class LoginWidgetOP extends StatelessWidget {
|
||||
config: op,
|
||||
curOP: curOP,
|
||||
cbLogin: cbLogin,
|
||||
startAuth: startAuth,
|
||||
cancelAuth: cancelAuth,
|
||||
canStartAuth: canStartAuth,
|
||||
),
|
||||
const Divider(
|
||||
indent: 5,
|
||||
@@ -434,12 +683,11 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
translate('Login'),
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
onPressed:
|
||||
curOP.value.isEmpty || curOP.value == 'rustdesk'
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
onPressed: curOP.value.isEmpty && !isInProgress
|
||||
? () {
|
||||
onLogin();
|
||||
}
|
||||
: null,
|
||||
)),
|
||||
),
|
||||
])),
|
||||
@@ -450,8 +698,28 @@ class LoginWidgetUserPass extends StatelessWidget {
|
||||
|
||||
const kAuthReqTypeOidc = 'oidc/';
|
||||
|
||||
Future<bool?>? _activeLoginDialog;
|
||||
|
||||
// call this directly
|
||||
Future<bool?> loginDialog() async {
|
||||
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();
|
||||
@@ -461,12 +729,13 @@ Future<bool?> loginDialog() async {
|
||||
String? usernameMsg;
|
||||
String? passwordMsg;
|
||||
var isInProgress = false;
|
||||
final RxString curOP = ''.obs;
|
||||
final oidcAuth = _OidcAuthController();
|
||||
final curOP = oidcAuth.curOP;
|
||||
// Track hover state for the close icon
|
||||
bool isCloseHovered = false;
|
||||
|
||||
final loginOptions = [].obs;
|
||||
final loginOptionsError = Rxn<String>();
|
||||
final loginOptionsError = Rxn<Object>();
|
||||
final loginOptionsInProgress = false.obs;
|
||||
fetchLoginOptions() async {
|
||||
loginOptionsInProgress.value = true;
|
||||
@@ -475,7 +744,7 @@ Future<bool?> loginDialog() async {
|
||||
loginOptionsError.value = null;
|
||||
} catch (e) {
|
||||
debugPrint("queryOidcLoginOptions failed: $e");
|
||||
loginOptionsError.value = e.toString();
|
||||
loginOptionsError.value = e;
|
||||
} finally {
|
||||
loginOptionsInProgress.value = false;
|
||||
}
|
||||
@@ -555,6 +824,9 @@ Future<bool?> loginDialog() async {
|
||||
}
|
||||
|
||||
onLogin() async {
|
||||
if (curOP.value.isNotEmpty || isInProgress) {
|
||||
return;
|
||||
}
|
||||
// validate
|
||||
if (username.text.isEmpty) {
|
||||
setState(() => usernameMsg = translate('Username missed'));
|
||||
@@ -593,7 +865,7 @@ Future<bool?> loginDialog() async {
|
||||
const SizedBox(height: 8.0),
|
||||
// NOT use Offstage to wrap LinearProgressIndicator
|
||||
if (inProgress) const LinearProgressIndicator(),
|
||||
if (!inProgress)
|
||||
if (!inProgress && error is! RequestException)
|
||||
Text(
|
||||
translate('network_error_tip'),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
@@ -608,7 +880,7 @@ Future<bool?> loginDialog() async {
|
||||
),
|
||||
if (!inProgress)
|
||||
SelectableText(
|
||||
error,
|
||||
error.toString(),
|
||||
style: const TextStyle(fontSize: 11, color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -635,6 +907,9 @@ Future<bool?> loginDialog() async {
|
||||
.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 {
|
||||
@@ -716,7 +991,7 @@ Future<bool?> loginDialog() async {
|
||||
onCancel: onDialogCancel,
|
||||
onSubmit: onLogin,
|
||||
);
|
||||
});
|
||||
}).whenComplete(oidcAuth.close);
|
||||
|
||||
if (res != null) {
|
||||
await UserModel.updateOtherModels();
|
||||
|
||||
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
157
flutter/lib/common/widgets/oidc_auth_status.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _statusFontSize = 12.0;
|
||||
const _statusSpacing = 4.0;
|
||||
const _messageActionSpacing = 8.0;
|
||||
const _desktopActionSize = 28.0;
|
||||
const _touchPlatforms = <TargetPlatform>{
|
||||
TargetPlatform.android,
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.fuchsia,
|
||||
};
|
||||
|
||||
class OidcAuthStatus extends StatelessWidget {
|
||||
final String message;
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const OidcAuthStatus({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messageStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectableText(message, style: messageStyle),
|
||||
if (authUrl.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _messageActionSpacing),
|
||||
child: _OidcAuthFallback(
|
||||
browserFallbackPrompt: browserFallbackPrompt,
|
||||
authUrl: authUrl,
|
||||
copyLabel: copyLabel,
|
||||
onCopy: onCopy,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OidcAuthFallback extends StatefulWidget {
|
||||
final String browserFallbackPrompt;
|
||||
final String authUrl;
|
||||
final String copyLabel;
|
||||
final VoidCallback? onCopy;
|
||||
|
||||
const _OidcAuthFallback({
|
||||
required this.browserFallbackPrompt,
|
||||
required this.authUrl,
|
||||
required this.copyLabel,
|
||||
required this.onCopy,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OidcAuthFallback> createState() => _OidcAuthFallbackState();
|
||||
}
|
||||
|
||||
class _OidcAuthFallbackState extends State<_OidcAuthFallback> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _OidcAuthFallback oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.authUrl != widget.authUrl) {
|
||||
_expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final helperStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||
fontSize: _statusFontSize,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final linkColor = theme.brightness == Brightness.dark
|
||||
? Colors.blue.shade300
|
||||
: Colors.blue.shade800;
|
||||
final isTouchPlatform = _touchPlatforms.contains(theme.platform);
|
||||
final actionSize =
|
||||
isTouchPlatform ? kMinInteractiveDimension : _desktopActionSize;
|
||||
final urlStyle =
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: _statusFontSize);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.browserFallbackPrompt,
|
||||
style: helperStyle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _statusSpacing),
|
||||
child: _buildUrl(urlStyle, linkColor, actionSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _copyAndExpand() {
|
||||
setState(() => _expanded = true);
|
||||
widget.onCopy?.call();
|
||||
}
|
||||
|
||||
Widget _buildUrl(TextStyle urlStyle, Color linkColor, double actionSize) {
|
||||
final collapsedUrl = SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: linkColor,
|
||||
minimumSize: Size(0, actionSize),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
),
|
||||
onPressed: _copyAndExpand,
|
||||
child: Text(
|
||||
widget.authUrl,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
style: urlStyle.copyWith(
|
||||
color: linkColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final collapsedChild = widget.onCopy == null
|
||||
? collapsedUrl
|
||||
: Tooltip(message: widget.copyLabel, child: collapsedUrl);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(minHeight: actionSize),
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _messageActionSpacing),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
borderRadius: BorderRadius.circular(_statusSpacing),
|
||||
),
|
||||
child: _expanded
|
||||
? SelectableText(widget.authUrl, style: urlStyle)
|
||||
: collapsedChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user