Redesign login page. Add OAuth2 support.

This commit is contained in:
Igor Kulikov
2021-06-10 13:12:58 +03:00
parent 8c519540ba
commit 27013f88e7
12 changed files with 317 additions and 81 deletions

View File

@@ -1 +0,0 @@
const thingsBoardApiEndpoint = 'http://localhost:8080';

View File

@@ -0,0 +1,4 @@
abstract class ThingsboardAppConstants {
static final thingsBoardApiEndpoint = 'http://localhost:8080';
static final thingsboardOAuth2CallbackUrlScheme = 'org.thingsboard.app.auth';
}

View File

@@ -5,4 +5,11 @@ abstract class ThingsboardImage {
static final thingsboardCenter = 'assets/images/thingsboard_center.svg';
static final dashboardPlaceholder = 'assets/images/dashboard-placeholder.png';
static final deviceProfilePlaceholder = 'assets/images/device-profile-placeholder.png';
static final oauth2Logos = <String,String>{
'google-logo': 'assets/images/google-logo.svg',
'github-logo': 'assets/images/github-logo.svg',
'facebook-logo': 'assets/images/facebook-logo.svg'
};
}

View File

@@ -1,14 +1,17 @@
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_web_auth/flutter_web_auth.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:thingsboard_app/constants/app_constants.dart';
import 'package:thingsboard_app/constants/assets_path.dart';
import 'package:thingsboard_app/widgets/tb_progress_indicator.dart';
import 'package:thingsboard_client/thingsboard_client.dart';
import 'package:thingsboard_app/core/context/tb_context.dart';
import 'package:thingsboard_app/core/context/tb_context_widget.dart';
import 'package:thingsboard_app/widgets/tb_progress_indicator.dart';
import 'package:thingsboard_client/thingsboard_client.dart';
class LoginPage extends TbPageWidget<LoginPage, _LoginPageState> {
@@ -22,6 +25,7 @@ class LoginPage extends TbPageWidget<LoginPage, _LoginPageState> {
class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
final _isLoginNotifier = ValueNotifier<bool>(false);
final _showPasswordNotifier = ValueNotifier<bool>(false);
final usernameController = TextEditingController();
final passwordController = TextEditingController();
@@ -40,91 +44,199 @@ class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
@override
Widget build(BuildContext context) {
final ButtonStyle oauth2ButtonStyle =
ElevatedButton.styleFrom(primary: Theme.of(context).secondaryHeaderColor,
onPrimary: Theme.of(context).colorScheme.onSurface);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('Login to ThingsBoard'),
),
body: ValueListenableBuilder(
resizeToAvoidBottomInset: false,
body: ValueListenableBuilder<bool>(
valueListenable: _isLoginNotifier,
builder: (BuildContext context, bool loading, child) {
List<Widget> children = [
SingleChildScrollView(
LoginPageBackground(),
Padding(
padding: EdgeInsets.fromLTRB(28, 71, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 60.0),
child: Center(
child: Container(
width: 300,
height: 150,
child: SvgPicture.asset(ThingsboardImage.thingsBoardWithTitle,
color: Theme.of(context).primaryColor,
semanticsLabel: 'ThingsBoard Logo')
)
Row(
children: [
SvgPicture.asset(ThingsboardImage.thingsBoardWithTitle,
height: 25,
color: Theme.of(context).primaryColor,
semanticsLabel: 'ThingsBoard Logo')
]
),
Container(height: 32),
Row(
children: [
Text(
'Login to your account',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 32,
height: 40 / 32
)
)]
),
Padding(
//padding: const EdgeInsets.only(left:15.0,right: 15.0,top:0,bottom: 0),
padding: EdgeInsets.symmetric(horizontal: 15),
child: TextField(
enabled: !loading,
controller: usernameController,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Username (email)',
hintText: 'Enter valid email id as abc@gmail.com'),
Container(height: tbContext.hasOAuthClients ? 24 : 48),
if (tbContext.hasOAuthClients)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: tbContext.oauth2Clients!.map((client) {
Widget? icon;
if (client.icon != null) {
if (ThingsboardImage.oauth2Logos.containsKey(client.icon)) {
icon = SvgPicture.asset(ThingsboardImage.oauth2Logos[client.icon]!,
height: 24);
} else {
String strIcon = client.icon!;
if (strIcon.startsWith('mdi:')) {
strIcon = strIcon.substring(4);
}
var iconData = MdiIcons.fromString(strIcon);
if (iconData != null) {
icon = Icon(iconData, size: 24, color: Theme.of(context).primaryColor);
}
}
}
if (icon == null) {
icon = Icon(Icons.login, size: 24, color: Theme.of(context).primaryColor);
}
return ElevatedButton.icon(
style: oauth2ButtonStyle,
onPressed: () async {
_isLoginNotifier.value = true;
var url = Uri.parse(ThingsboardAppConstants.thingsBoardApiEndpoint + client.url);
var params = Map<String,String>.from(url.queryParameters);
params['pkg'] = tbContext.packageName;
url = url.replace(queryParameters: params);
try {
final result = await FlutterWebAuth.authenticate(
url: url.toString(),
callbackUrlScheme: ThingsboardAppConstants.thingsboardOAuth2CallbackUrlScheme);
final resultUri = Uri.parse(result);
final error = resultUri.queryParameters['error'];
if (error != null) {
_isLoginNotifier.value = false;
showErrorNotification(error);
} else {
final accessToken = resultUri.queryParameters['accessToken'];
final refreshToken = resultUri.queryParameters['refreshToken'];
if (accessToken != null && refreshToken != null) {
await tbClient.setUserFromJwtToken(accessToken, refreshToken, true);
}
}
log.debug('result = $result');
} catch (e) {
log.error('Auth Error:', e);
_isLoginNotifier.value = false;
}
},
icon: icon,
label: Text('Login with ${client.name}'));
}).toList(),
),
if (tbContext.hasOAuthClients)
Padding(padding: EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
Flexible(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('OR'),
),
Flexible(child: Divider())
],
)
),
Padding(
padding: const EdgeInsets.only(
left: 15.0, right: 15.0, top: 15, bottom: 0),
//padding: EdgeInsets.symmetric(horizontal: 15),
child: TextField(
enabled: !loading,
controller: passwordController,
obscureText: true,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Password',
hintText: 'Enter secure password'),
),
TextField(
enabled: !loading,
controller: usernameController,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Email',
hintText: 'Enter valid email id as abc@gmail.com'),
),
TextButton(
onPressed: loading ? null : () {
//TODO FORGOT PASSWORD SCREEN GOES HERE
Container(height: 28),
ValueListenableBuilder(
valueListenable: _showPasswordNotifier,
builder: (BuildContext context, bool showPassword, child) {
return TextField(
enabled: !loading,
controller: passwordController,
obscureText: !showPassword,
decoration: InputDecoration(
suffixIcon: IconButton(
icon: Icon(showPassword ? Icons.visibility : Icons.visibility_off),
onPressed: loading ? null : () {
_showPasswordNotifier.value = !_showPasswordNotifier.value;
},
),
border: OutlineInputBorder(),
labelText: 'Password',
hintText: 'Enter secure password'),
);
}
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: loading ? null : () {
//TODO FORGOT PASSWORD SCREEN GOES HERE
},
child: Text(
'Forgot Password?',
style: TextStyle(color: loading ? Colors.black12 : Theme.of(context).colorScheme.primary,
letterSpacing: 1,
fontSize: 12,
height: 16 / 12),
),
)
],
),
Spacer(),
ElevatedButton(
child: Text('Log In'),
style: ElevatedButton.styleFrom(padding: EdgeInsets.symmetric(vertical: 16)),
onPressed: loading ? null : () async {
_isLoginNotifier.value = true;
try {
await tbClient.login(
LoginRequest(usernameController.text,
passwordController.text));
} catch (e) {
_isLoginNotifier.value = false;
}
},
child: Text(
'Forgot Password?',
style: TextStyle(color: loading ? Colors.black12 : Theme.of(context).colorScheme.primary, fontSize: 15),
),
),
Container(
height: 50,
width: 250,
decoration: BoxDecoration(
color: loading ? Colors.black12 : Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(4)),
child: TextButton(
onPressed: loading ? null : () async {
_isLoginNotifier.value = true;
try {
await tbClient.login(
LoginRequest(usernameController.text,
passwordController.text));
} catch (e) {
_isLoginNotifier.value = false;
}
},
child: Text(
'Login',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
height: 24,
),
SizedBox(
height: 130,
),
Text('New User? Create Account')
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('New User?',
style: TextStyle(
fontSize: 14,
height: 14 / 20
)),
TextButton(
onPressed: loading ? null : () {
//TODO CREATE ACCOUNT SCREEN GOES HERE
},
child: Text(
'Create Account',
style: TextStyle(color: loading ? Colors.black12 : Theme.of(context).colorScheme.primary,
letterSpacing: 1,
fontSize: 14,
height: 14 / 20),
),
)
],
)
]
)
)
@@ -160,3 +272,47 @@ class _LoginPageState extends TbPageState<LoginPage, _LoginPageState> {
);
}
}
class LoginPageBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: CustomPaint(
painter: _LoginPageBackgroundPainter(color: Theme.of(context).primaryColor),
)
);
}
}
class _LoginPageBackgroundPainter extends CustomPainter {
final Color color;
const _LoginPageBackgroundPainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color.withAlpha(14);
paint.style = PaintingStyle.fill;
var topPath = Path();
topPath.moveTo(0, 0);
topPath.lineTo(size.width / 2, 0);
topPath.lineTo(0, size.height / 10);
topPath.close();
canvas.drawPath(topPath, paint);
var bottomPath = Path();
bottomPath.moveTo(0, size.height * 0.98);
bottomPath.lineTo(size.width, size.height * 0.78);
bottomPath.lineTo(size.width, size.height);
bottomPath.lineTo(0, size.height);
bottomPath.close();
canvas.drawPath(bottomPath, paint);
}
@override
bool shouldRepaint(covariant _LoginPageBackgroundPainter oldDelegate) {
return color != oldDelegate.color;
}
}

View File

@@ -6,11 +6,12 @@ import 'package:fluro/fluro.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:package_info/package_info.dart';
import 'package:thingsboard_app/constants/app_constants.dart';
import 'package:thingsboard_app/modules/main/main_page.dart';
import 'package:thingsboard_app/utils/services/widget_action_handler.dart';
import 'package:thingsboard_client/thingsboard_client.dart';
import 'package:thingsboard_app/utils/services/tb_secure_storage.dart';
import 'package:thingsboard_app/constants/api_path.dart';
import 'package:thingsboard_app/core/context/tb_context_widget.dart';
enum NotificationType {
@@ -106,6 +107,7 @@ class TbContext {
bool _initialized = false;
bool isUserLoaded = false;
final ValueNotifier<bool> _isAuthenticated = ValueNotifier(false);
List<OAuth2ClientInfo>? oauth2Clients;
User? userDetails;
HomeDashboardInfo? homeDashboard;
final _isLoadingNotifier = ValueNotifier<bool>(false);
@@ -113,6 +115,7 @@ class TbContext {
late final _widgetActionHandler;
late final AndroidDeviceInfo? _androidInfo;
late final IosDeviceInfo? _iosInfo;
late final String packageName;
TbMainDashboardHolder? _mainDashboardHolder;
GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
@@ -138,7 +141,7 @@ class TbContext {
return true;
}());
_initialized = true;
tbClient = ThingsboardClient(thingsBoardApiEndpoint,
tbClient = ThingsboardClient(ThingsboardAppConstants.thingsBoardApiEndpoint,
storage: TbSecureStorage(),
onUserLoaded: onUserLoaded,
onError: onError,
@@ -151,6 +154,8 @@ class TbContext {
} else if (Platform.isIOS) {
_iosInfo = await deviceInfoPlugin.iosInfo;
}
PackageInfo packageInfo = await PackageInfo.fromPlatform();
packageName = packageInfo.packageName;
await tbClient.init();
} catch (e, s) {
log.error('Failed to init tbContext: $e', e, s);
@@ -253,6 +258,7 @@ class TbContext {
} else {
userDetails = null;
homeDashboard = null;
oauth2Clients = await tbClient.getOAuth2Service().getOAuth2Clients(pkgName: packageName);
}
await updateRouteState();
@@ -265,6 +271,8 @@ class TbContext {
bool get isAuthenticated => _isAuthenticated.value;
bool get hasOAuthClients => oauth2Clients != null && oauth2Clients!.isNotEmpty;
Future<void> updateRouteState() async {
if (currentState != null) {
if (tbClient.isAuthenticated()) {
@@ -314,6 +322,17 @@ class TbContext {
}
}
String userAgent() {
String userAgent = 'Mozilla/5.0';
if (Platform.isAndroid) {
userAgent += ' (Linux; Android ${_androidInfo!.version.release}; ${_androidInfo!.model})';
} else if (Platform.isIOS) {
userAgent += ' (${_iosInfo!.model})';
}
userAgent += ' AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36';
return userAgent;
}
bool isHomePage() {
if (currentState != null) {
if (currentState is TbMainState) {
@@ -440,7 +459,7 @@ mixin HasTbContext {
Future<dynamic> navigateTo(String path, {bool replace = false, bool clearStack = false}) => _tbContext.navigateTo(path, replace: replace, clearStack: clearStack);
void pop<T>([T? result]) => _tbContext.pop<T>(result);
void pop<T>([T? result, BuildContext? context]) => _tbContext.pop<T>(result, context);
Future<bool> maybePop<T extends Object?>([ T? result ]) => _tbContext.maybePop<T>(result);

View File

@@ -5,7 +5,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:thingsboard_app/constants/api_path.dart';
import 'package:thingsboard_app/constants/app_constants.dart';
import 'package:thingsboard_app/core/context/tb_context.dart';
import 'package:thingsboard_app/core/context/tb_context_widget.dart';
import 'package:thingsboard_app/widgets/tb_progress_indicator.dart';
@@ -135,7 +135,7 @@ class _DashboardState extends TbContextState<Dashboard, _DashboardState> {
void _onAuthenticated() async {
if (tbContext.isAuthenticated) {
if (!readyState.value) {
_initialUrl = Uri.parse(thingsBoardApiEndpoint + '?accessToken=${tbClient.getJwtToken()!}&refreshToken=${tbClient.getRefreshToken()!}');
_initialUrl = Uri.parse(ThingsboardAppConstants.thingsBoardApiEndpoint + '?accessToken=${tbClient.getJwtToken()!}&refreshToken=${tbClient.getRefreshToken()!}');
readyState.value = true;
} else {
var windowMessage = <String, dynamic>{