From 87e53501e3bbaed4c3a9475b70500f1357463ad2 Mon Sep 17 00:00:00 2001 From: fufesou Date: Thu, 20 Oct 2022 23:03:54 +0800 Subject: [PATCH] feat_account: mid commit Signed-off-by: fufesou --- flutter/lib/common/widgets/address_book.dart | 1 + .../lib/desktop/pages/desktop_home_page.dart | 2 +- .../desktop/pages/desktop_setting_page.dart | 1 + flutter/lib/desktop/widgets/login.dart | 469 ++++++++++++++++++ flutter/lib/models/user_model.dart | 4 +- src/flutter_ffi.rs | 10 + src/hbbs_http/account.rs | 86 ++-- src/ui_interface.rs | 13 +- 8 files changed, 536 insertions(+), 50 deletions(-) create mode 100644 flutter/lib/desktop/widgets/login.dart diff --git a/flutter/lib/common/widgets/address_book.dart b/flutter/lib/common/widgets/address_book.dart index 570ff6e95..c96dc115a 100644 --- a/flutter/lib/common/widgets/address_book.dart +++ b/flutter/lib/common/widgets/address_book.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart'; import 'package:flutter_hbb/common/widgets/peers_view.dart'; import 'package:flutter_hbb/desktop/widgets/popup_menu.dart'; +import 'package:flutter_hbb/desktop/widgets/login.dart'; import '../../consts.dart'; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; import 'package:get/get.dart'; diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index ad0aa4316..ef38bf443 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -472,7 +472,7 @@ class _DesktopHomePageState extends State /// common login dialog for desktop /// call this directly -Future loginDialog() async { +Future loginDialog2() async { String userName = ""; var userNameMsg = ""; String pass = ""; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index b063c3c15..12bb935e9 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart'; import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart'; +import 'package:flutter_hbb/desktop/widgets/login.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/server_model.dart'; import 'package:get/get.dart'; diff --git a/flutter/lib/desktop/widgets/login.dart b/flutter/lib/desktop/widgets/login.dart new file mode 100644 index 000000000..dec304cda --- /dev/null +++ b/flutter/lib/desktop/widgets/login.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/consts.dart'; +import 'package:flutter_hbb/models/platform_model.dart'; +import 'package:get/get.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../common.dart'; +import '../widgets/button.dart'; + +class _IconOP extends StatelessWidget { + final String icon; + final double iconWidth; + const _IconOP({Key? key, required this.icon, required this.iconWidth}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4.0), + child: SvgPicture.asset( + 'assets/$icon.svg', + width: iconWidth, + ), + ); + } +} + +class ButtonOP extends StatelessWidget { + final String op; + final RxString curOP; + final double iconWidth; + final Color primaryColor; + final double height; + final Function() onTap; + + const ButtonOP({ + Key? key, + required this.op, + required this.curOP, + required this.iconWidth, + required this.primaryColor, + required this.height, + required this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row(children: [ + Expanded( + child: Container( + height: height, + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: Obx(() => ElevatedButton( + style: ElevatedButton.styleFrom( + primary: 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: Stack(children: [ + // to-do: translate + Center(child: Text('Continue with $op')), + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 120, + child: _IconOP( + icon: op, + iconWidth: iconWidth, + )), + ), + ]), + )), + ), + ) + ]); + } +} + +class ConfigOP { + final String op; + final double iconWidth; + ConfigOP({required this.op, required this.iconWidth}); +} + +class WidgetOP extends StatefulWidget { + final ConfigOP config; + final RxString curOP; + const WidgetOP({ + Key? key, + required this.config, + required this.curOP, + }) : super(key: key); + + @override + State createState() { + return _WidgetOPState(); + } +} + +class _WidgetOPState extends State { + Timer? _updateTimer; + String _stateMsg = ''; + String _stateFailedMsg = ''; + String _url = ''; + String _username = ''; + + @override + void initState() { + super.initState(); + } + + @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']; + final String failedMsg = resultMap['failed_msg']; + // to-do: test null url + final String url = resultMap['url']; + if (_stateMsg != stateMsg) { + if (_url.isEmpty && url.isNotEmpty) { + launchUrl(Uri.parse(url)); + _url = url; + } + setState(() { + _stateMsg = stateMsg; + _stateFailedMsg = failedMsg; + }); + } + }); + } + + _resetState() { + _stateMsg = ''; + _stateFailedMsg = ''; + _url = ''; + _username = ''; + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 500), + child: Column( + children: [ + ButtonOP( + op: widget.config.op, + curOP: widget.curOP, + iconWidth: widget.config.iconWidth, + primaryColor: str2color(widget.config.op, 0x7f), + height: 40, + onTap: () { + widget.curOP.value = widget.config.op; + bind.mainAccountAuth(op: widget.config.op); + _beginQueryState(); + }, + ), + Obx(() => Offstage( + offstage: widget.curOP.value != widget.config.op, + child: Text( + _stateMsg, + style: TextStyle(fontSize: 12), + ))), + 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(); + }, + child: Text( + translate('Cancel'), + style: TextStyle(fontSize: 15), + ), + ), + ), + ), + ), + ], + )); + } +} + +class LoginWidgetOP extends StatelessWidget { + final List ops; + final RxString curOP = ''.obs; + + LoginWidgetOP({ + Key? key, + required this.ops, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var children = ops + .map((op) => [ + WidgetOP( + config: op, + curOP: curOP, + ), + const Divider() + ]) + .expand((i) => i) + .toList(); + if (children.isNotEmpty) { + children.removeLast(); + } + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children, + )); + } +} + +class LoginWidgetUserPass extends StatelessWidget { + final String username; + final String pass; + final String usernameMsg; + final String passMsg; + final bool isInProgress; + final Function(String, String) onLogin; + const LoginWidgetUserPass({ + Key? key, + required this.username, + required this.pass, + required this.usernameMsg, + required this.passMsg, + required this.isInProgress, + required this.onLogin, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var userController = TextEditingController(text: username); + var pwdController = TextEditingController(text: pass); + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 500), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 100), + child: Text( + '${translate("Username")}:', + textAlign: TextAlign.start, + ).marginOnly(bottom: 16.0)), + const SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + errorText: usernameMsg.isNotEmpty ? usernameMsg : null), + controller: userController, + focusNode: FocusNode()..requestFocus(), + ), + ), + ], + ), + const SizedBox( + height: 8.0, + ), + Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 100), + child: Text('${translate("Password")}:') + .marginOnly(bottom: 16.0)), + const SizedBox( + width: 24.0, + ), + Expanded( + child: TextField( + obscureText: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + errorText: passMsg.isNotEmpty ? passMsg : null), + controller: pwdController, + ), + ), + ], + ), + const SizedBox( + height: 4.0, + ), + Offstage( + offstage: !isInProgress, child: const LinearProgressIndicator()), + const SizedBox( + height: 12.0, + ), + Row(children: [ + Expanded( + child: Container( + height: 50, + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: ElevatedButton( + child: const Text( + 'Login', + style: TextStyle(fontSize: 18), + ), + onPressed: () { + onLogin(userController.text, pwdController.text); + }, + ), + ), + ), + ]), + ], + ), + ); + } +} + +/// common login dialog for desktop +/// call this directly +Future loginDialog() async { + String username = ''; + var usernameMsg = ''; + String pass = ''; + var passMsg = ''; + var isInProgress = false; + var completer = Completer(); + + gFFI.dialogManager.show((setState, close) { + cancel() { + isInProgress = false; + completer.complete(false); + close(); + } + + onLogin(String username0, String pass0) async { + setState(() { + usernameMsg = ''; + passMsg = ''; + isInProgress = true; + }); + cancel() { + if (isInProgress) { + setState(() { + isInProgress = false; + }); + } + } + + username = username0; + pass = pass0; + if (username.isEmpty) { + usernameMsg = translate('Username missed'); + debugPrint('REMOVE ME ====================== username empty'); + cancel(); + return; + } + if (pass.isEmpty) { + passMsg = translate('Password missed'); + debugPrint('REMOVE ME ====================== password empty'); + cancel(); + return; + } + try { + final resp = await gFFI.userModel.login(username, pass); + if (resp.containsKey('error')) { + passMsg = resp['error']; + debugPrint('REMOVE ME ====================== password error'); + cancel(); + return; + } + // {access_token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJndWlkIjoiMDFkZjQ2ZjgtZjg3OS00MDE0LTk5Y2QtMGMwYzM2MmViZGJlIiwiZXhwIjoxNjYxNDg2NzYwfQ.GZpe1oI8TfM5yTYNrpcwbI599P4Z_-b2GmnwNl2Lr-w, + // token_type: Bearer, user: {id: , name: admin, email: null, note: null, status: null, grp: null, is_admin: true}} + debugPrint('$resp'); + completer.complete(true); + } catch (err) { + debugPrint(err.toString()); + debugPrint( + 'REMOVE ME ====================== login error ${err.toString()}'); + cancel(); + return; + } + close(); + } + + return CustomAlertDialog( + title: Text(translate('Login')), + content: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 500), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 8.0, + ), + LoginWidgetUserPass( + username: username, + pass: pass, + usernameMsg: usernameMsg, + passMsg: passMsg, + isInProgress: isInProgress, + onLogin: onLogin, + ), + const SizedBox( + height: 8.0, + ), + const Center( + child: Text( + 'or', + style: TextStyle(fontSize: 16), + )), + const SizedBox( + height: 8.0, + ), + LoginWidgetOP(ops: [ + ConfigOP(op: 'Github', iconWidth: 24), + ConfigOP(op: 'Google', iconWidth: 24), + ConfigOP(op: 'Okta', iconWidth: 46), + ]), + ], + ), + ), + actions: [ + TextButton(onPressed: cancel, child: Text(translate('Cancel'))), + ], + onCancel: cancel, + ); + }); + return completer.future; +} diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index 721aac5b5..cfbe71cfe 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -77,7 +77,9 @@ class UserModel { return ""; } final m = jsonDecode(userInfo); - if (m != null) { + if (m == null) { + userName.value = ''; + } else { userName.value = m['name'] ?? ''; } return userName.value; diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index f20eb6153..937c108ed 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -13,6 +13,8 @@ use hbb_common::{ fs, log, }; +// use crate::hbbs_http::account::AuthResult; + use crate::flutter::{self, SESSIONS}; #[cfg(target_os = "android")] use crate::start_server; @@ -1082,6 +1084,14 @@ pub fn install_install_path() -> SyncReturn { SyncReturn(install_path()) } +pub fn main_account_auth(op: String) { + account_auth(op); +} + +pub fn main_account_auth_result() -> String { + account_auth_result() +} + #[cfg(target_os = "android")] pub mod server_side { use jni::{ diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 85b2f7e82..4a2e365f4 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -1,6 +1,6 @@ use super::HbbHttpResponse; use hbb_common::{config::Config, log, sleep, tokio, tokio::sync::RwLock, ResultType}; -use serde_derive::Deserialize; +use serde_derive::{Deserialize, Serialize}; use std::{ collections::HashMap, sync::Arc, @@ -16,6 +16,9 @@ lazy_static::lazy_static! { const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_TIMEOUT_SECS: u64 = 60; +const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; +const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; +const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; #[derive(Deserialize, Clone)] pub struct OidcAuthUrl { @@ -23,7 +26,7 @@ pub struct OidcAuthUrl { url: Url, } -#[derive(Debug, Deserialize, Default, Clone)] +#[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct UserPayload { pub id: String, pub name: String, @@ -34,34 +37,16 @@ pub struct UserPayload { pub is_admin: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthBody { - access_token: String, - token_type: String, - user: UserPayload, -} - -#[derive(Copy, Clone)] -pub enum OidcState { - // initial request - OidcRequest = 1, - // initial request failed - OidcRequestFailed = 2, - // request succeeded, loop querying - OidcQuerying = 11, - // loop querying failed - OidcQueryFailed = 12, - // query sucess before - OidcNotExists = 13, - // query timeout - OidcQueryTimeout = 14, - // already login - OidcLogin = 21, + pub access_token: String, + pub token_type: String, + pub user: UserPayload, } pub struct OidcSession { client: reqwest::Client, - state: OidcState, + state_msg: &'static str, failed_msg: String, code_url: Option, auth_body: Option, @@ -70,11 +55,19 @@ pub struct OidcSession { query_timeout: Duration, } +#[derive(Serialize)] +pub struct AuthResult { + pub state_msg: String, + pub failed_msg: String, + pub url: Option, + pub auth_body: Option, +} + impl OidcSession { fn new() -> Self { Self { client: reqwest::Client::new(), - state: OidcState::OidcRequest, + state_msg: REQUESTING_ACCOUNT_AUTH, failed_msg: "".to_owned(), code_url: None, auth_body: None, @@ -112,7 +105,7 @@ impl OidcSession { } fn reset(&mut self) { - self.state = OidcState::OidcRequest; + self.state_msg = REQUESTING_ACCOUNT_AUTH; self.failed_msg = "".to_owned(); self.keep_querying = true; self.running = false; @@ -136,21 +129,21 @@ impl OidcSession { OIDC_SESSION .write() .await - .set_state(OidcState::OidcRequestFailed, err); + .set_state(REQUESTING_ACCOUNT_AUTH, err); return; } Ok(_) => { - OIDC_SESSION.write().await.set_state( - OidcState::OidcRequestFailed, - "Invalid auth response".to_owned(), - ); + OIDC_SESSION + .write() + .await + .set_state(REQUESTING_ACCOUNT_AUTH, "Invalid auth response".to_owned()); return; } Err(err) => { OIDC_SESSION .write() .await - .set_state(OidcState::OidcRequestFailed, err.to_string()); + .set_state(REQUESTING_ACCOUNT_AUTH, err.to_string()); return; } }; @@ -158,7 +151,7 @@ impl OidcSession { OIDC_SESSION .write() .await - .set_state(OidcState::OidcQuerying, "".to_owned()); + .set_state(WAITING_ACCOUNT_AUTH, "".to_owned()); OIDC_SESSION.write().await.code_url = Some(code_url.clone()); let begin = Instant::now(); @@ -169,7 +162,7 @@ impl OidcSession { OIDC_SESSION .write() .await - .set_state(OidcState::OidcLogin, "".to_owned()); + .set_state(LOGIN_ACCOUNT_AUTH, "".to_owned()); OIDC_SESSION.write().await.auth_body = Some(auth_body); return; // to-do, set access-token @@ -181,7 +174,7 @@ impl OidcSession { OIDC_SESSION .write() .await - .set_state(OidcState::OidcQueryFailed, err); + .set_state(WAITING_ACCOUNT_AUTH, err); return; } } @@ -200,14 +193,14 @@ impl OidcSession { OIDC_SESSION .write() .await - .set_state(OidcState::OidcQueryTimeout, "timeout".to_owned()); + .set_state(WAITING_ACCOUNT_AUTH, "timeout".to_owned()); } // no need to handle "keep_querying == false" } - fn set_state(&mut self, state: OidcState, failed_msg: String) { - self.state = state; + fn set_state(&mut self, state_msg: &'static str, failed_msg: String) { + self.state_msg = state_msg; self.failed_msg = failed_msg; } @@ -228,15 +221,16 @@ impl OidcSession { }); } - fn get_result_(&self) -> (u8, String, Option) { - ( - self.state as u8, - self.failed_msg.clone(), - self.auth_body.clone(), - ) + fn get_result_(&self) -> AuthResult { + AuthResult { + state_msg: self.state_msg.to_string(), + failed_msg: self.failed_msg.clone(), + url: self.code_url.as_ref().map(|x| x.url.to_string()), + auth_body: self.auth_body.clone(), + } } - pub async fn get_result() -> (u8, String, Option) { + pub async fn get_result() -> AuthResult { OIDC_SESSION.read().await.get_result_() } } diff --git a/src/ui_interface.rs b/src/ui_interface.rs index cb2c178d6..78d9433b1 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -20,8 +20,7 @@ use hbb_common::{ tokio::{self, sync::mpsc, time}, }; -use crate::ipc; -use crate::{common::SOFTWARE_UPDATE_URL, platform}; +use crate::{common::SOFTWARE_UPDATE_URL, hbbs_http::account, ipc, platform}; type Message = RendezvousMessage; @@ -843,6 +842,16 @@ pub(crate) fn check_connect_status(reconnect: bool) -> mpsc::UnboundedSender String { + serde_json::to_string(&account::OidcSession::get_result().await).unwrap_or_default() +} + // notice: avoiding create ipc connecton repeatly, // because windows named pipe has serious memory leak issue. #[tokio::main(flavor = "current_thread")]