From fce45d4cad541b639d45ddba4b9ab0929e4a70fb Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Sun, 21 Aug 2022 19:43:04 +0930 Subject: [PATCH 1/3] Update the profile page to show the QR code on dropdown Add change password screen and endpoint --- Backend/Api/Auth/ChangePassword.go | 76 +++++ Backend/Api/Auth/Check.go | 1 + Backend/Api/Auth/Signup.go | 9 +- Backend/Api/Routes.go | 2 + Backend/Database/Seeder/Seed.go | 1 + mobile/lib/main.dart | 1 + mobile/lib/views/authentication/signup.dart | 270 +++++++++--------- .../views/main/profile/change_password.dart | 180 ++++++++++++ mobile/lib/views/main/profile/profile.dart | 269 +++++++++++------ mobile/pubspec.lock | 7 + mobile/pubspec.yaml | 1 + 11 files changed, 585 insertions(+), 232 deletions(-) create mode 100644 Backend/Api/Auth/ChangePassword.go create mode 100644 mobile/lib/views/main/profile/change_password.dart diff --git a/Backend/Api/Auth/ChangePassword.go b/Backend/Api/Auth/ChangePassword.go new file mode 100644 index 0000000..f4335cc --- /dev/null +++ b/Backend/Api/Auth/ChangePassword.go @@ -0,0 +1,76 @@ +package Auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" +) + +type rawChangePassword struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` +} + +// ChangePassword handle change password action +func ChangePassword(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + changePassword rawChangePassword + requestBody []byte + err error + ) + + user, err = CheckCookieCurrentUser(w, r) + if err != nil { + // Don't bother showing an error here, as the middleware handles auth + return + } + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &changePassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + if !CheckPasswordHash(changePassword.OldPassword, user.Password) { + http.Error(w, "Invalid Current Password", http.StatusForbidden) + return + } + + // This should never occur, due to frontend validation + if changePassword.NewPassword != changePassword.NewPasswordConfirm { + http.Error(w, "Invalid New Password", http.StatusUnprocessableEntity) + return + } + + user.Password, err = HashPassword(changePassword.NewPassword) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + // Private key doesn't change at this point, is just re-encrypted with the new password + user.AsymmetricPrivateKey = changePassword.PrivateKey + + err = Database.UpdateUser( + user.ID.String(), + &user, + ) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Auth/Check.go b/Backend/Api/Auth/Check.go index e503183..a5f49ba 100644 --- a/Backend/Api/Auth/Check.go +++ b/Backend/Api/Auth/Check.go @@ -4,6 +4,7 @@ import ( "net/http" ) +// Check is used to check session viability func Check(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } diff --git a/Backend/Api/Auth/Signup.go b/Backend/Api/Auth/Signup.go index 57509ab..b60f880 100644 --- a/Backend/Api/Auth/Signup.go +++ b/Backend/Api/Auth/Signup.go @@ -18,15 +18,15 @@ type signupResponse struct { func makeSignupResponse(w http.ResponseWriter, code int, message string) { var ( - status string = "error" - returnJson []byte + status = "error" + returnJSON []byte err error ) if code > 200 && code < 300 { status = "success" } - returnJson, err = json.MarshalIndent(signupResponse{ + returnJSON, err = json.MarshalIndent(signupResponse{ Status: status, Message: message, }, "", " ") @@ -37,10 +37,11 @@ func makeSignupResponse(w http.ResponseWriter, code int, message string) { // Return updated json w.WriteHeader(code) - w.Write(returnJson) + w.Write(returnJSON) } +// Signup to the platform func Signup(w http.ResponseWriter, r *http.Request) { var ( userData Models.User diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 50f4f01..f7b8151 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -61,6 +61,8 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/check", Auth.Check).Methods("GET") + authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") + authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") diff --git a/Backend/Database/Seeder/Seed.go b/Backend/Database/Seeder/Seed.go index 7e9a373..7bd5c40 100644 --- a/Backend/Database/Seeder/Seed.go +++ b/Backend/Database/Seeder/Seed.go @@ -58,6 +58,7 @@ var ( decodedPrivateKey *rsa.PrivateKey ) +// Seed seeds semi random data for use in testing & development func Seed() { var ( block *pem.Block diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 656c188..01f32c8 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -49,6 +49,7 @@ class MyApp extends StatelessWidget { brightness: Brightness.dark, primaryColor: Colors.orange.shade900, backgroundColor: Colors.grey.shade800, + scaffoldBackgroundColor: Colors.grey[850], colorScheme: ColorScheme( brightness: Brightness.dark, primary: Colors.orange.shade900, diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index c9d3447..9522444 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -18,17 +18,17 @@ Future signUp(context, String username, String password, String // TODO: Check for timeout here final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - 'confirm_password': confirmPassword, - 'asymmetric_public_key': rsaPubPem, - 'asymmetric_private_key': encRsaPriv, - }), + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': username, + 'password': password, + 'confirm_password': confirmPassword, + 'asymmetric_public_key': rsaPubPem, + 'asymmetric_private_key': encRsaPriv, + }), ); SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); @@ -47,17 +47,17 @@ class Signup extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: null, - automaticallyImplyLeading: true, - //`true` if you want Flutter to automatically add Back Button when needed, - //or `false` if you want to force your own back button every where - leading: IconButton(icon: const Icon(Icons.arrow_back), - onPressed:() => { - Navigator.pop(context) - } - ), - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, + title: null, + automaticallyImplyLeading: true, + //`true` if you want Flutter to automatically add Back Button when needed, + //or `false` if you want to force your own back button every where + leading: IconButton(icon: const Icon(Icons.arrow_back), + onPressed:() => { + Navigator.pop(context) + } + ), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, ), body: const SafeArea( child: SignupWidget(), @@ -77,8 +77,8 @@ class SignupResponse { factory SignupResponse.fromJson(Map json) { return SignupResponse( - status: json['status'], - message: json['message'], + status: json['status'], + message: json['message'], ); } } @@ -93,9 +93,9 @@ class SignupWidget extends StatefulWidget { class _SignupWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController usernameController = TextEditingController(); - TextEditingController passwordController = TextEditingController(); - TextEditingController passwordConfirmController = TextEditingController(); + TextEditingController _usernameController = TextEditingController(); + TextEditingController _passwordController = TextEditingController(); + TextEditingController _passwordConfirmController = TextEditingController(); @override Widget build(BuildContext context) { @@ -123,114 +123,116 @@ class _SignupWidgetState extends State { ); return Center( - child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 100, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Sign Up', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - const SizedBox(height: 30), - TextFormField( - controller: usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Create a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordConfirmController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Confirm your password'; - } - if (value != passwordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - signUp( - context, - usernameController.text, - passwordController.text, - passwordConfirmController.text - ).then((value) { - Navigator.of(context).popUntil((route) => route.isFirst); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - } - }, - child: const Text('Submit'), - ), - ], - ) - ) + child: Form( + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 100, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Sign Up', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + hintText: 'Username', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Create a username'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordConfirmController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Confirm Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + signUp( + context, + _usernameController.text, + _passwordController.text, + _passwordConfirmController.text + ).then((value) { + Navigator.of(context).popUntil((route) => route.isFirst); + }).catchError((error) { + print(error); // TODO: Show error on interface + }); + }, + child: const Text('Submit'), + ), + ], + ) ) + ) ) ); } diff --git a/mobile/lib/views/main/profile/change_password.dart b/mobile/lib/views/main/profile/change_password.dart new file mode 100644 index 0000000..3b2e6f8 --- /dev/null +++ b/mobile/lib/views/main/profile/change_password.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:http/http.dart' as http; +import 'package:flutter/material.dart'; +import 'package:pointycastle/impl.dart'; + +import '/components/flash_message.dart'; +import '/components/custom_title_bar.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; +import '/utils/storage/session_cookie.dart'; + +@immutable +class ChangePassword extends StatelessWidget { + ChangePassword({ + Key? key, + required this.privateKey + }) : super(key: key); + + final RSAPrivateKey privateKey; + + final _formKey = GlobalKey(); + + final TextEditingController _currentPasswordController = TextEditingController(); + final TextEditingController _newPasswordController = TextEditingController(); + final TextEditingController _newPasswordConfirmController = TextEditingController(); + + bool invalidCurrentPassword = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Change Password', + style: TextStyle( + fontSize: 25, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _currentPasswordController, + decoration: const InputDecoration( + hintText: 'Current Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter your current password'; + } + if (invalidCurrentPassword) { + return 'Invalid password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'New Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a new password'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _newPasswordConfirmController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: const InputDecoration( + hintText: 'Confirm Password', + ), + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _newPasswordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + _changePassword(context) + .then((dynamic) { + Navigator.of(context).pop(); + }); + }, + child: const Text('Submit'), + ), + ], + ) + ) + ) + ); + } + + Future _changePassword(BuildContext context) async { + String privateKeyPem = CryptoUtils.encodeRSAPrivateKeyToPem(privateKey); + + String privateKeyEncrypted = AesHelper.aesEncrypt( + _newPasswordController.text, + Uint8List.fromList(privateKeyPem.codeUnits), + ); + + String payload = jsonEncode({ + 'old_password': _currentPasswordController.text, + 'new_password': _newPasswordController.text, + 'new_password_confirm': _newPasswordConfirmController.text, + 'private_key': privateKeyEncrypted, + }); + + var resp = await http.post( + Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/change_password'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'cookie': await getSessionCookie(), + }, + body: payload, + ); + + if (resp.statusCode == 403) { + invalidCurrentPassword = true; + _formKey.currentState!.validate(); + return; + } + + if (resp.statusCode != 200) { + showMessage( + 'An unexpected error occured, please try again later.', + context, + ); + } + } +} + diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index 5127da0..b25770d 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,16 @@ -import 'package:Envelope/components/custom_title_bar.dart'; +import 'dart:convert'; + +import 'package:Envelope/utils/encryption/crypto_utils.dart'; +import 'package:Envelope/views/main/profile/change_password.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import '/utils/storage/database.dart'; -import '/models/my_profile.dart'; +import 'package:sliding_up_panel/sliding_up_panel.dart'; + import '/components/custom_circle_avatar.dart'; +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/storage/database.dart'; class Profile extends StatefulWidget { final MyProfile profile; @@ -18,81 +24,81 @@ class Profile extends StatefulWidget { } class _ProfileState extends State { - Widget usernameHeading() { - return Row( - children: [ - const CustomCircleAvatar( - icon: Icon(Icons.person, size: 40), - imagePath: null, // TODO: Add image here - radius: 30, - ), - const SizedBox(width: 20), - Text( - widget.profile.username, - style: const TextStyle( - fontSize: 25, - fontWeight: FontWeight.w500, - ), - ), - // widget.conversation.admin ? IconButton( - // iconSize: 20, - // icon: const Icon(Icons.edit), - // padding: const EdgeInsets.all(5.0), - // splashRadius: 25, - // onPressed: () { - // // TODO: Redirect to edit screen - // }, - // ) : const SizedBox.shrink(), - ], - ); - } + final PanelController _panelController = PanelController(); - Widget _profileQrCode() { - return Container( - child: QrImage( - data: 'This is a simple QR code', - version: QrVersions.auto, - size: 130, - gapless: true, + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: false, + backgroundColor: Colors.transparent, + ), + body: SlidingUpPanel( + controller: _panelController, + slideDirection: SlideDirection.DOWN, + defaultPanelState: PanelState.CLOSED, + color: Theme.of(context).scaffoldBackgroundColor, + backdropTapClosesPanel: true, + backdropEnabled: true, + backdropOpacity: 0.2, + minHeight: 0, + maxHeight: 450, + panel: Center( + child: _profileQrCode(), ), - width: 130, - height: 130, - color: Theme.of(context).colorScheme.onPrimary, + body: Padding( + padding: const EdgeInsets.only(top: 16,left: 16,right: 16), + child: Column( + children: [ + usernameHeading(), + const SizedBox(height: 30), + settings(), + const SizedBox(height: 30), + logout(), + ], + ) + ), + ), ); } - Widget settings() { - return Align( - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 5), - TextButton.icon( - label: const Text( - 'Disappearing Messages', - style: TextStyle(fontSize: 16) - ), - icon: const Icon(Icons.timer), - style: ButtonStyle( - alignment: Alignment.centerLeft, - foregroundColor: MaterialStateProperty.resolveWith( - (Set states) { - return Theme.of(context).colorScheme.onBackground; - }, - ) - ), - onPressed: () { - print('Disappearing Messages'); - } + Widget usernameHeading() { + return Row( + children: [ + const CustomCircleAvatar( + icon: Icon(Icons.person, size: 40), + imagePath: null, // TODO: Add image here + radius: 30, + ), + const SizedBox(width: 20), + Expanded( + flex: 1, + child: Text( + widget.profile.username, + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.w500, ), - ], - ), + ), + ), + IconButton( + onPressed: () => _panelController.open(), + icon: const Icon(Icons.qr_code_2), + ), + ], ); } Widget logout() { - bool isTesting = dotenv.env["ENVIRONMENT"] == 'development'; + bool isTesting = dotenv.env['ENVIRONMENT'] == 'development'; + return Align( alignment: Alignment.centerLeft, child: Column( @@ -131,34 +137,109 @@ class _ProfileState extends State { ); } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: const CustomTitleBar( - title: Text( - 'Profile', - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold - ) - ), - showBack: false, - backgroundColor: Colors.transparent, + Widget settings() { + return Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Disappearing Messages', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.timer), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + print('Disappearing Messages'); + } + ), + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Server URL', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.dataset_linked_outlined), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + print('Server URL'); + } + ), + const SizedBox(height: 5), + TextButton.icon( + label: const Text( + 'Change Password', + style: TextStyle(fontSize: 16) + ), + icon: const Icon(Icons.password), + style: ButtonStyle( + alignment: Alignment.centerLeft, + foregroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context).colorScheme.onBackground; + }, + ) + ), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ChangePassword( + privateKey: widget.profile.privateKey!, + )) + ); + } + ), + ], ), - body: Padding( - padding: const EdgeInsets.only(top: 16,left: 16,right: 16), - child: Column( - children: [ - usernameHeading(), - const SizedBox(height: 30), - _profileQrCode(), - const SizedBox(height: 30), - settings(), - const SizedBox(height: 30), - logout(), - ], - ) + ); + } + + Widget _profileQrCode() { + String payload = jsonEncode({ + 'i': widget.profile.id, + 'u': widget.profile.username, + 'k': base64.encode( + CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits ), + }); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: QrImage( + backgroundColor: Theme.of(context).colorScheme.primary, + data: payload, + version: QrVersions.auto, + gapless: true, + ), + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 20), + child: IconButton( + onPressed: () => _panelController.close(), + icon: const Icon(Icons.arrow_upward), + ), + ), + ), + ] ); } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 917ab66..128580d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -322,6 +322,13 @@ packages: description: flutter source: sdk version: "0.0.99" + sliding_up_panel: + dependency: "direct main" + description: + name: sliding_up_panel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" source_span: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6007c51..9a4d284 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: uuid: ^3.0.6 qr_flutter: ^4.0.0 qr_code_scanner: ^1.0.1 + sliding_up_panel: ^2.0.0+1 dev_dependencies: flutter_test: From e31b048389b00a632c9fb563ee41bf0769151ecc Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Mon, 22 Aug 2022 20:34:41 +0930 Subject: [PATCH 2/3] Add the ability to change the server URL on signup & profile page TODO: Change server URL on login. This will only affect new devices with existing users, so will be done later. --- Backend/Database/Init.go | 22 +- Backend/Database/Seeder/MessageSeeder.go | 2 +- Backend/Models/Messages.go | 16 +- Backend/Models/Users.go | 52 ++- mobile/lib/components/qr_reader.dart | 5 +- mobile/lib/components/user_search_result.dart | 2 +- mobile/lib/main.dart | 1 + mobile/lib/models/my_profile.dart | 28 +- mobile/lib/utils/storage/conversations.dart | 11 +- mobile/lib/utils/storage/friends.dart | 3 +- mobile/lib/utils/storage/messages.dart | 7 +- mobile/lib/views/authentication/login.dart | 292 +++++++------- mobile/lib/views/authentication/signup.dart | 362 +++++++++++------- mobile/lib/views/main/friend/add_search.dart | 5 +- .../views/main/friend/request_list_item.dart | 11 +- mobile/lib/views/main/home.dart | 3 +- .../views/main/profile/change_password.dart | 4 +- .../views/main/profile/change_server_url.dart | 142 +++++++ mobile/lib/views/main/profile/profile.dart | 10 +- 19 files changed, 632 insertions(+), 346 deletions(-) create mode 100644 mobile/lib/views/main/profile/change_server_url.dart diff --git a/Backend/Database/Init.go b/Backend/Database/Init.go index 4124949..4481002 100644 --- a/Backend/Database/Init.go +++ b/Backend/Database/Init.go @@ -10,13 +10,14 @@ import ( ) const ( - dbUrl = "postgres://postgres:@localhost:5432/envelope" - dbTestUrl = "postgres://postgres:@localhost:5432/envelope_test" + dbURL = "postgres://postgres:@localhost:5432/envelope" + dbTestURL = "postgres://postgres:@localhost:5432/envelope_test" ) +// DB db var DB *gorm.DB -func GetModels() []interface{} { +func getModels() []interface{} { return []interface{}{ &Models.Session{}, &Models.User{}, @@ -29,6 +30,7 @@ func GetModels() []interface{} { } } +// Init initializes the database connection func Init() { var ( model interface{} @@ -37,7 +39,7 @@ func Init() { log.Println("Initializing database...") - DB, err = gorm.Open(postgres.Open(dbUrl), &gorm.Config{}) + DB, err = gorm.Open(postgres.Open(dbURL), &gorm.Config{}) if err != nil { log.Fatalln(err) @@ -45,24 +47,28 @@ func Init() { log.Println("Running AutoMigrate...") - for _, model = range GetModels() { - DB.AutoMigrate(model) + for _, model = range getModels() { + err = DB.AutoMigrate(model) + if err != nil { + log.Fatalln(err) + } } } +// InitTest initializes the test datbase func InitTest() { var ( model interface{} err error ) - DB, err = gorm.Open(postgres.Open(dbTestUrl), &gorm.Config{}) + DB, err = gorm.Open(postgres.Open(dbTestURL), &gorm.Config{}) if err != nil { log.Fatalln(err) } - for _, model = range GetModels() { + for _, model = range getModels() { DB.Migrator().DropTable(model) DB.AutoMigrate(model) } diff --git a/Backend/Database/Seeder/MessageSeeder.go b/Backend/Database/Seeder/MessageSeeder.go index 0480131..1bdffb9 100644 --- a/Backend/Database/Seeder/MessageSeeder.go +++ b/Backend/Database/Seeder/MessageSeeder.go @@ -298,7 +298,7 @@ func SeedMessages() { panic(err) } - for i = 0; i <= 20; i++ { + for i = 0; i <= 100; i++ { err = seedMessage( primaryUser, secondaryUser, diff --git a/Backend/Models/Messages.go b/Backend/Models/Messages.go index 663d72d..9e995b5 100644 --- a/Backend/Models/Messages.go +++ b/Backend/Models/Messages.go @@ -1,12 +1,14 @@ package Models import ( + "database/sql" "time" "github.com/gofrs/uuid" ) -// TODO: Add support for images +// MessageData holds the content of the message +// encrypted through the Message.SymmetricKey type MessageData struct { Base Data string `gorm:"not null" json:"data"` // Stored encrypted @@ -14,11 +16,13 @@ type MessageData struct { SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted } +// Message holds data pertaining to each users' message type Message struct { Base - MessageDataID uuid.UUID `json:"message_data_id"` - MessageData MessageData `json:"message_data"` - SymmetricKey string `json:"symmetric_key" gorm:"not null"` // Stored encrypted - AssociationKey string `json:"association_key" gorm:"not null"` // TODO: This links all encrypted messages for a user in a thread together. Find a way to fix this - CreatedAt time.Time `json:"created_at" gorm:"not null"` + MessageDataID uuid.UUID ` json:"message_data_id"` + MessageData MessageData ` json:"message_data"` + SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + AssociationKey string `gorm:"not null" json:"association_key"` // Stored encrypted + Expiry sql.NullTime ` json:"expiry"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` } diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 4727e26..33d2069 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -1,10 +1,12 @@ package Models import ( + "database/sql/driver" + "gorm.io/gorm" ) -// Prevent updating the email if it has not changed +// BeforeUpdate prevents updating the email if it has not changed // This stops a unique constraint error func (u *User) BeforeUpdate(tx *gorm.DB) (err error) { if !tx.Statement.Changed("Username") { @@ -13,11 +15,49 @@ func (u *User) BeforeUpdate(tx *gorm.DB) (err error) { return nil } +// MessageExpiry holds values for how long messages should expire by default +type MessageExpiry []uint8 + +const ( + // MessageExpiryFifteenMin expires after 15 minutes + MessageExpiryFifteenMin = "fifteen_min" + // MessageExpiryThirtyMin expires after 30 minutes + MessageExpiryThirtyMin = "thirty_min" + // MessageExpiryOneHour expires after one hour + MessageExpiryOneHour = "one_hour" + // MessageExpiryThreeHour expires after three hours + MessageExpiryThreeHour = "three_hour" + // MessageExpirySixHour expires after six hours + MessageExpirySixHour = "six_hour" + // MessageExpiryTwelveHour expires after twelve hours + MessageExpiryTwelveHour = "twelve_hour" + // MessageExpiryOneDay expires after one day + MessageExpiryOneDay = "one_day" + // MessageExpiryThreeDay expires after three days + MessageExpiryThreeDay = "three_day" + // MessageExpiryNoExpiry never expires + MessageExpiryNoExpiry = "no_expiry" +) + +// Scan new value into MessageExpiry +func (e *MessageExpiry) Scan(value interface{}) error { + *e = MessageExpiry(value.(string)) + return nil +} + +// Value gets value out of MessageExpiry column +func (e MessageExpiry) Value() (driver.Value, error) { + return string(e), nil +} + +// User holds user data type User struct { Base - Username string `gorm:"not null;unique" json:"username"` - Password string `gorm:"not null" json:"password"` - ConfirmPassword string `gorm:"-" json:"confirm_password"` - AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted - AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` + Username string `gorm:"not null;unique" json:"username"` + Password string `gorm:"not null" json:"password"` + ConfirmPassword string `gorm:"-" json:"confirm_password"` + AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted + AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` + MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"message_expiry_default" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted + } diff --git a/mobile/lib/components/qr_reader.dart b/mobile/lib/components/qr_reader.dart index 1ff79ed..25ea239 100644 --- a/mobile/lib/components/qr_reader.dart +++ b/mobile/lib/components/qr_reader.dart @@ -1,9 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:Envelope/utils/storage/session_cookie.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:qr_code_scanner/qr_code_scanner.dart'; import 'package:sqflite/sqflite.dart'; @@ -16,6 +14,7 @@ import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; import '/utils/strings.dart'; +import '/utils/storage/session_cookie.dart'; import 'flash_message.dart'; class QrReader extends StatefulWidget { @@ -128,7 +127,7 @@ class _QrReaderState extends State { ]); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/qr_code'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/qr_code'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': await getSessionCookie(), diff --git a/mobile/lib/components/user_search_result.dart b/mobile/lib/components/user_search_result.dart index 4b0155d..c8c7b95 100644 --- a/mobile/lib/components/user_search_result.dart +++ b/mobile/lib/components/user_search_result.dart @@ -119,7 +119,7 @@ class _UserSearchResultState extends State{ }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request'), + await MyProfile.getServerUrl('api/v1/auth/friend_request'), headers: { 'cookie': await getSessionCookie(), }, diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 01f32c8..ce2ea93 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -50,6 +50,7 @@ class MyApp extends StatelessWidget { primaryColor: Colors.orange.shade900, backgroundColor: Colors.grey.shade800, scaffoldBackgroundColor: Colors.grey[850], + disabledColor: Colors.grey[400], colorScheme: ColorScheme( brightness: Brightness.dark, primary: Colors.orange.shade900, diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 0c0207f..07ec14a 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -1,9 +1,16 @@ import 'dart:convert'; -import 'package:Envelope/utils/encryption/aes_helper.dart'; -import 'package:Envelope/utils/encryption/crypto_utils.dart'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '/utils/encryption/aes_helper.dart'; +import '/utils/encryption/crypto_utils.dart'; + +// TODO: Replace this with the prod url when server is deployed +String defaultServerUrl = dotenv.env['SERVER_URL'] ?? 'http://192.168.1.5:8080'; + + class MyProfile { String id; String username; @@ -107,5 +114,22 @@ class MyProfile { } return profile.privateKey!; } + + static setServerUrl(String url) async { + final preferences = await SharedPreferences.getInstance(); + preferences.setString('server_url', url); + } + + static Future getServerUrl(String path) async { + final preferences = await SharedPreferences.getInstance(); + + String? baseUrl = preferences.getString('server_url'); + if (baseUrl == null) { + setServerUrl(defaultServerUrl); + return Uri.parse('$defaultServerUrl$path'); + } + + return Uri.parse('$baseUrl$path'); + } } diff --git a/mobile/lib/utils/storage/conversations.dart b/mobile/lib/utils/storage/conversations.dart index fc65477..985b9ea 100644 --- a/mobile/lib/utils/storage/conversations.dart +++ b/mobile/lib/utils/storage/conversations.dart @@ -1,12 +1,11 @@ import 'dart:convert'; -import 'package:Envelope/components/flash_message.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; +import '/components/flash_message.dart'; import '/models/conversation_users.dart'; import '/models/conversations.dart'; import '/models/my_profile.dart'; @@ -20,7 +19,7 @@ Future updateConversation(Conversation conversation, { includeUsers = true Map conversationJson = await conversation.payloadJson(includeUsers: includeUsers); var x = await http.put( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, @@ -38,7 +37,7 @@ Future updateConversations() async { // try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'cookie': await getSessionCookie(), } @@ -68,7 +67,7 @@ Future updateConversations() async { Map params = {}; params['conversation_detail_ids'] = conversationsDetailIds.join(','); - var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversation_details'); + var uri = await MyProfile.getServerUrl('api/v1/auth/conversation_details'); uri = uri.replace(queryParameters: params); resp = await http.get( @@ -150,7 +149,7 @@ Future uploadConversation(Conversation conversation, BuildContext context) Map conversationJson = await conversation.payloadJson(); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'), + await MyProfile.getServerUrl('api/v1/auth/conversations'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, diff --git a/mobile/lib/utils/storage/friends.dart b/mobile/lib/utils/storage/friends.dart index 9ed41eb..52fe63e 100644 --- a/mobile/lib/utils/storage/friends.dart +++ b/mobile/lib/utils/storage/friends.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:pointycastle/export.dart'; import 'package:sqflite/sqflite.dart'; @@ -15,7 +14,7 @@ Future updateFriends() async { // try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_requests'), + await MyProfile.getServerUrl('api/v1/auth/friend_requests'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index b551672..da715e0 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -43,9 +42,9 @@ Future sendMessage(Conversation conversation, String data) async { String sessionCookie = await getSessionCookie(); message.payloadJson(conversation, messageId) - .then((messageJson) { + .then((messageJson) async { return http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'), + await MyProfile.getServerUrl('api/v1/auth/message'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': sessionCookie, @@ -75,7 +74,7 @@ Future updateMessageThread(Conversation conversation, {MyProfile? profile} ConversationUser currentUser = await getConversationUser(conversation, profile.id); var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${currentUser.associationKey}'), + await MyProfile.getServerUrl('api/v1/auth/messages/${currentUser.associationKey}'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index b608703..be227a6 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -1,7 +1,9 @@ import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:flutter_dotenv/flutter_dotenv.dart'; + +import '/components/flash_message.dart'; import '/models/my_profile.dart'; import '/utils/storage/session_cookie.dart'; @@ -34,51 +36,26 @@ class LoginResponse { } } -Future login(context, String username, String password) async { - final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/login'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - }), - ); - - if (resp.statusCode != 200) { - throw Exception(resp.body); - } - - String? rawCookie = resp.headers['set-cookie']; - if (rawCookie != null) { - int index = rawCookie.indexOf(';'); - setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index)); - } - - return await MyProfile.login(json.decode(resp.body), password); -} - class Login extends StatelessWidget { const Login({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: null, - automaticallyImplyLeading: true, - leading: IconButton(icon: const Icon(Icons.arrow_back), - onPressed:() => { - Navigator.pop(context) - } - ), - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - ), - body: const SafeArea( - child: LoginWidget(), + appBar: AppBar( + title: null, + automaticallyImplyLeading: true, + leading: IconButton(icon: const Icon(Icons.arrow_back), + onPressed:() => { + Navigator.pop(context) + } ), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + body: const SafeArea( + child: LoginWidget(), + ), ); } } @@ -93,125 +70,154 @@ class LoginWidget extends StatefulWidget { class _LoginWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController usernameController = TextEditingController(); - TextEditingController passwordController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); @override Widget build(BuildContext context) { const TextStyle inputTextStyle = TextStyle( - fontSize: 18, + fontSize: 18, ); final OutlineInputBorder inputBorderStyle = OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: Colors.transparent, - ) + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide( + color: Colors.transparent, + ) ); final ButtonStyle buttonStyle = ElevatedButton.styleFrom( - primary: Theme.of(context).colorScheme.surface, - onPrimary: Theme.of(context).colorScheme.onSurface, - minimumSize: const Size.fromHeight(50), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - textStyle: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.error, - ), + primary: Theme.of(context).colorScheme.surface, + onPrimary: Theme.of(context).colorScheme.onSurface, + minimumSize: const Size.fromHeight(50), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + textStyle: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.error, + ), ); return Center( - child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 80, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Login', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, - ), - ), - const SizedBox(height: 30), - TextFormField( - controller: usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, - ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (_formKey.currentState!.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - login( - context, - usernameController.text, - passwordController.text, - ).then((val) { - Navigator. - pushNamedAndRemoveUntil( - context, - '/home', - ModalRoute.withName('/home'), - ); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - } - }, - child: const Text('Submit'), - ), - ], - ) - ) + child: Form( + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 80, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Login', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(height: 30), + TextFormField( + controller: _usernameController, + decoration: InputDecoration( + hintText: 'Username', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a username'; + } + return null; + }, + ), + const SizedBox(height: 10), + TextFormField( + controller: _passwordController, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: 'Password', + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + login() + .then((val) { + Navigator. + pushNamedAndRemoveUntil( + context, + '/home', + ModalRoute.withName('/home'), + ); + }).catchError((error) { + showMessage( + 'Could not login to Envelope, please try again later.', + context, + ); + }); + } + }, + child: const Text('Submit'), + ), + ], + ) ) + ) ) ); } + + + Future login() async { + final resp = await http.post( + await MyProfile.getServerUrl('api/v1/login'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': _usernameController.text, + 'password': _passwordController.text, + }), + ); + + if (resp.statusCode != 200) { + throw Exception(resp.body); + } + + String? rawCookie = resp.headers['set-cookie']; + if (rawCookie != null) { + int index = rawCookie.indexOf(';'); + setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index)); + } + + return await MyProfile.login( + json.decode(resp.body), + _passwordController.text, + ); + } } diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index 9522444..2a190e0 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -1,44 +1,14 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/models/my_profile.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; -Future signUp(context, String username, String password, String confirmPassword) async { - var keyPair = CryptoUtils.generateRSAKeyPair(); - - var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey); - var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey); - - String encRsaPriv = AesHelper.aesEncrypt(password, Uint8List.fromList(rsaPrivPem.codeUnits)); - - // TODO: Check for timeout here - final resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'username': username, - 'password': password, - 'confirm_password': confirmPassword, - 'asymmetric_public_key': rsaPubPem, - 'asymmetric_private_key': encRsaPriv, - }), - ); - - SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); - - if (resp.statusCode != 201) { - throw Exception(response.message); - } - - return response; -} class Signup extends StatelessWidget { const Signup({Key? key}) : super(key: key); @@ -93,22 +63,26 @@ class SignupWidget extends StatefulWidget { class _SignupWidgetState extends State { final _formKey = GlobalKey(); - TextEditingController _usernameController = TextEditingController(); - TextEditingController _passwordController = TextEditingController(); - TextEditingController _passwordConfirmController = TextEditingController(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _passwordConfirmController = TextEditingController(); + final TextEditingController _serverUrlController = TextEditingController(); + + bool showUrlInput = false; + + final OutlineInputBorder inputBorderStyle = OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: const BorderSide( + color: Colors.transparent, + ) + ); + + final TextStyle inputTextStyle = const TextStyle( + fontSize: 18, + ); @override Widget build(BuildContext context) { - const TextStyle inputTextStyle = TextStyle( - fontSize: 18, - ); - - final OutlineInputBorder inputBorderStyle = OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - borderSide: const BorderSide( - color: Colors.transparent, - ) - ); final ButtonStyle buttonStyle = ElevatedButton.styleFrom( primary: Theme.of(context).colorScheme.surface, @@ -123,117 +97,209 @@ class _SignupWidgetState extends State { ); return Center( - child: Form( - key: _formKey, - child: Center( - child: Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - top: 0, - bottom: 100, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Sign Up', - style: TextStyle( - fontSize: 35, - color: Theme.of(context).colorScheme.onBackground, + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Center( + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 0, + bottom: 100, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Sign Up', + style: TextStyle( + fontSize: 35, + color: Theme.of(context).colorScheme.onBackground, + ), ), - ), - const SizedBox(height: 30), - TextFormField( - controller: _usernameController, - decoration: InputDecoration( - hintText: 'Username', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, + const SizedBox(height: 30), + input( + _usernameController, + 'Username', + false, + (value) { + if (value == null || value.isEmpty) { + return 'Create a username'; + } + return null; + }, ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Create a username'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: _passwordController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, + const SizedBox(height: 10), + input( + _passwordController, + 'Password', + true, + (value) { + if (value == null || value.isEmpty) { + return 'Enter a password'; + } + return null; + }, ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Enter a password'; - } - return null; - }, - ), - const SizedBox(height: 10), - TextFormField( - controller: _passwordConfirmController, - obscureText: true, - enableSuggestions: false, - autocorrect: false, - decoration: InputDecoration( - hintText: 'Confirm Password', - enabledBorder: inputBorderStyle, - focusedBorder: inputBorderStyle, + const SizedBox(height: 10), + input( + _passwordConfirmController, + 'Confirm Password', + true, + (value) { + if (value == null || value.isEmpty) { + return 'Confirm your password'; + } + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, ), - style: inputTextStyle, - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return 'Confirm your password'; - } - if (value != _passwordController.text) { - return 'Passwords do not match'; - } - return null; - }, - ), - const SizedBox(height: 15), - ElevatedButton( - style: buttonStyle, - onPressed: () { - if (!_formKey.currentState!.validate()) { - return; - } - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Processing Data')), - ); - - signUp( - context, - _usernameController.text, - _passwordController.text, - _passwordConfirmController.text - ).then((value) { - Navigator.of(context).popUntil((route) => route.isFirst); - }).catchError((error) { - print(error); // TODO: Show error on interface - }); - }, - child: const Text('Submit'), - ), - ], + const SizedBox(height: 15), + serverUrl(), + const SizedBox(height: 15), + ElevatedButton( + style: buttonStyle, + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + signUp() + .then((dynamic) { + Navigator.of(context).popUntil((route) => route.isFirst); + }).catchError((error) { + showMessage('Failed to signup to Envelope, please try again later', context); + }); + }, + child: const Text('Submit'), + ), + ], + ) ) ) ) ) ); } + + Widget input( + TextEditingController textController, + String hintText, + bool password, + String? Function(dynamic) validationFunction, + ) { + return TextFormField( + controller: textController, + obscureText: password, + enableSuggestions: false, + autocorrect: false, + decoration: InputDecoration( + hintText: hintText, + enabledBorder: inputBorderStyle, + focusedBorder: inputBorderStyle, + ), + style: inputTextStyle, + validator: validationFunction, + ); + } + + Widget serverUrl() { + if (!showUrlInput) { + return + Padding( + padding: const EdgeInsets.only(top: 0, bottom: 10), + child: Row( + children: [ + SizedBox( + height: 10, + child: IconButton( + onPressed: () { + setState(() { + showUrlInput = true; + }); + }, + icon: Icon( + Icons.edit, + color: Theme.of(context).disabledColor, + ), + splashRadius: 2, + padding: const EdgeInsets.all(2), + iconSize: 15, + ), + ), + const SizedBox(width: 2), + Column( + children: [ + const SizedBox(height: 10), + Text( + 'Server URL - $defaultServerUrl', + style: TextStyle( + color: Theme.of(context).disabledColor, + fontSize: 12, + ), + ), + ], + ), + ], + ), + ); + } + + if (_serverUrlController.text == '') { + _serverUrlController.text = defaultServerUrl; + } + + return input( + _serverUrlController, + 'Server URL', + false, + (dynamic) { + return null; + }, + ); + } + + Future signUp() async { + await MyProfile.setServerUrl(_serverUrlController.text); + + var keyPair = CryptoUtils.generateRSAKeyPair(); + + var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey); + var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey); + + String encRsaPriv = AesHelper.aesEncrypt( + _passwordController.text, + Uint8List.fromList(rsaPrivPem.codeUnits), + ); + + final resp = await http.post( + await MyProfile.getServerUrl('api/v1/signup'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'username': _usernameController.text, + 'password': _passwordController.text, + 'confirm_password': _passwordConfirmController.text, + 'asymmetric_public_key': rsaPubPem, + 'asymmetric_private_key': encRsaPriv, + }), + ); + + SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body)); + + if (resp.statusCode != 201) { + throw Exception(response.message); + } + + return response; + } } diff --git a/mobile/lib/views/main/friend/add_search.dart b/mobile/lib/views/main/friend/add_search.dart index 08b09d3..3b10e38 100644 --- a/mobile/lib/views/main/friend/add_search.dart +++ b/mobile/lib/views/main/friend/add_search.dart @@ -2,12 +2,11 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import '/utils/storage/session_cookie.dart'; import '/components/user_search_result.dart'; import '/data_models/user_search.dart'; - +import '/models/my_profile.dart'; class FriendAddSearch extends StatefulWidget { const FriendAddSearch({ @@ -123,7 +122,7 @@ class _FriendAddSearchState extends State { Map params = {}; params['username'] = searchController.text; - var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/users'); + var uri = await MyProfile.getServerUrl('api/v1/auth/users'); uri = uri.replace(queryParameters: params); var resp = await http.get( diff --git a/mobile/lib/views/main/friend/request_list_item.dart b/mobile/lib/views/main/friend/request_list_item.dart index 21b81b0..0f2c278 100644 --- a/mobile/lib/views/main/friend/request_list_item.dart +++ b/mobile/lib/views/main/friend/request_list_item.dart @@ -1,16 +1,15 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:Envelope/components/flash_message.dart'; -import 'package:Envelope/utils/storage/database.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; +import '/components/flash_message.dart'; import '/components/custom_circle_avatar.dart'; import '/models/friends.dart'; -import '/utils/storage/session_cookie.dart'; import '/models/my_profile.dart'; +import '/utils/storage/session_cookie.dart'; +import '/utils/storage/database.dart'; import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/strings.dart'; @@ -122,7 +121,7 @@ class _FriendRequestListItemState extends State { }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'), headers: { 'cookie': await getSessionCookie(), }, @@ -153,7 +152,7 @@ class _FriendRequestListItemState extends State { Future rejectFriendRequest() async { var resp = await http.delete( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'), + await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'), headers: { 'cookie': await getSessionCookie(), }, diff --git a/mobile/lib/views/main/home.dart b/mobile/lib/views/main/home.dart index f6bfb92..b069fe6 100644 --- a/mobile/lib/views/main/home.dart +++ b/mobile/lib/views/main/home.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import '/models/conversations.dart'; @@ -94,7 +93,7 @@ class _HomeState extends State { int statusCode = 200; try { var resp = await http.get( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/check'), + await MyProfile.getServerUrl('api/v1/auth/check'), headers: { 'cookie': await getSessionCookie(), } diff --git a/mobile/lib/views/main/profile/change_password.dart b/mobile/lib/views/main/profile/change_password.dart index 3b2e6f8..2f77448 100644 --- a/mobile/lib/views/main/profile/change_password.dart +++ b/mobile/lib/views/main/profile/change_password.dart @@ -1,13 +1,13 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:pointycastle/impl.dart'; import '/components/flash_message.dart'; import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; import '/utils/encryption/aes_helper.dart'; import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/session_cookie.dart'; @@ -155,7 +155,7 @@ class ChangePassword extends StatelessWidget { }); var resp = await http.post( - Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/change_password'), + await MyProfile.getServerUrl('api/v1/auth/change_password'), headers: { 'Content-Type': 'application/json; charset=UTF-8', 'cookie': await getSessionCookie(), diff --git a/mobile/lib/views/main/profile/change_server_url.dart b/mobile/lib/views/main/profile/change_server_url.dart new file mode 100644 index 0000000..813397b --- /dev/null +++ b/mobile/lib/views/main/profile/change_server_url.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '/components/custom_title_bar.dart'; +import '/models/my_profile.dart'; +import '/utils/storage/database.dart'; + +@immutable +class ChangeServerUrl extends StatefulWidget { + const ChangeServerUrl({ + Key? key, + }) : super(key: key); + + @override + State createState() => _ChangeServerUrl(); +} + +class _ChangeServerUrl extends State { + final _formKey = GlobalKey(); + + final TextEditingController _serverUrlController = TextEditingController(); + + bool invalidCurrentPassword = false; + + @override + void initState() { + setUrl(); + super.initState(); + } + + Future setUrl() async { + final preferences = await SharedPreferences.getInstance(); + _serverUrlController.text = preferences.getString('server_url') ?? defaultServerUrl; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomTitleBar( + title: Text( + 'Profile', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + ), + body: SingleChildScrollView( + child: Center( + child: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + top: 30, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Change Server Url', + style: TextStyle( + fontSize: 25, + ), + ), + const SizedBox(height: 30), + showWarning(), + const SizedBox(height: 30), + TextFormField( + controller: _serverUrlController, + decoration: const InputDecoration( + hintText: 'Server Url', + ), + // The validator receives the text that the user has entered. + validator: (String? value) { + if (value == null || !Uri.parse(value).isAbsolute) { + return 'Invalid URL'; + } + return null; + }, + ), + const SizedBox(height: 15), + ElevatedButton( + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Processing Data')), + ); + + }, + child: const Text('CHANGE SERVER URL'), + ), + ], + ) + ) + ) + ) + ) + ); + } + + Widget showWarning() { + String warning1 = ''' +WARNING: Do not use this feature unless you know what you\'re doing! +'''; + + String warning2 = ''' +Changing the server url will disconnect you from all friends and conversations on this server, and connect you to a fresh environment. This feature is intended to be used by people that are willing to host their own Envelope server, which you can find by going to \nhttps://github.com/SomeUsername/SomeRepo.\n\n +You can revert this by entering \nhttps://envelope-messenger.com\n on the login screen. +'''; + + return Column( + children: [ + Text( + warning1, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ) + ), + Text( + warning2, + textAlign: TextAlign.center, + ), + ], + ); + } + + // TODO: Write user data to new server?? + Future changeUrl() async { + MyProfile.setServerUrl(_serverUrlController.text); + deleteDb(); + MyProfile.logout(); + Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing')); + } +} diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index b25770d..bedadcf 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,7 +1,5 @@ import 'dart:convert'; -import 'package:Envelope/utils/encryption/crypto_utils.dart'; -import 'package:Envelope/views/main/profile/change_password.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -10,7 +8,10 @@ import 'package:sliding_up_panel/sliding_up_panel.dart'; import '/components/custom_circle_avatar.dart'; import '/components/custom_title_bar.dart'; import '/models/my_profile.dart'; +import '/utils/encryption/crypto_utils.dart'; import '/utils/storage/database.dart'; +import '/views/main/profile/change_password.dart'; +import '/views/main/profile/change_server_url.dart'; class Profile extends StatefulWidget { final MyProfile profile; @@ -178,7 +179,10 @@ class _ProfileState extends State { ) ), onPressed: () { - print('Server URL'); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ChangeServerUrl( + )) + ); } ), const SizedBox(height: 5), From 54068e805d5ddf4c492f02f1ab02b8ae621e3187 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Wed, 24 Aug 2022 18:57:31 +0930 Subject: [PATCH 3/3] Update the profile page Add change password page and route Add disappearing messages page and route Add the ability to change the server URL Update the look and feel of the qr code --- Backend/Api/Auth/ChangeMessageExpiry.go | 52 ++++++++ Backend/Api/Auth/Login.go | 25 ++-- Backend/Api/Routes.go | 1 + Backend/Models/Users.go | 2 +- mobile/lib/components/custom_title_bar.dart | 8 +- mobile/lib/components/select_message_ttl.dart | 108 +++++++++++++++++ mobile/lib/models/my_profile.dart | 6 +- mobile/lib/views/authentication/login.dart | 1 - .../lib/views/main/conversation/detail.dart | 6 +- mobile/lib/views/main/conversation/list.dart | 2 +- .../views/main/conversation/list_item.dart | 113 +++++++++--------- mobile/lib/views/main/home.dart | 14 ++- mobile/lib/views/main/profile/profile.dart | 42 ++++++- 13 files changed, 301 insertions(+), 79 deletions(-) create mode 100644 Backend/Api/Auth/ChangeMessageExpiry.go create mode 100644 mobile/lib/components/select_message_ttl.dart diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go new file mode 100644 index 0000000..8f8721f --- /dev/null +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -0,0 +1,52 @@ +package Auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" +) + +type rawChangeMessageExpiry struct { + MessageExpiry string `json:"message_exipry"` +} + +// ChangeMessageExpiry handles changing default message expiry for user +func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { + var ( + user Models.User + changeMessageExpiry rawChangeMessageExpiry + requestBody []byte + err error + ) + + // Ignore error here, as middleware should handle auth + user, _ = CheckCookieCurrentUser(w, r) + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &changeMessageExpiry) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry + + err = Database.UpdateUser( + user.ID.String(), + &user, + ) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index 44f26e7..61225af 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -1,6 +1,7 @@ package Auth import ( + "database/sql/driver" "encoding/json" "net/http" "time" @@ -9,7 +10,7 @@ import ( "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" ) -type Credentials struct { +type credentials struct { Username string `json:"username"` Password string `json:"password"` } @@ -21,25 +22,32 @@ type loginResponse struct { AsymmetricPrivateKey string `json:"asymmetric_private_key"` UserID string `json:"user_id"` Username string `json:"username"` + MessageExpiryDefault string `json:"message_expiry_default"` } func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) { var ( - status string = "error" - returnJson []byte - err error + status = "error" + messageExpiryRaw driver.Value + messageExpiry string + returnJSON []byte + err error ) - if code > 200 && code < 300 { + if code >= 200 && code <= 300 { status = "success" } - returnJson, err = json.MarshalIndent(loginResponse{ + messageExpiryRaw, _ = user.MessageExpiryDefault.Value() + messageExpiry, _ = messageExpiryRaw.(string) + + returnJSON, err = json.MarshalIndent(loginResponse{ Status: status, Message: message, AsymmetricPublicKey: pubKey, AsymmetricPrivateKey: privKey, UserID: user.ID.String(), Username: user.Username, + MessageExpiryDefault: messageExpiry, }, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) @@ -48,12 +56,13 @@ func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey // Return updated json w.WriteHeader(code) - w.Write(returnJson) + w.Write(returnJSON) } +// Login logs the user into the system func Login(w http.ResponseWriter, r *http.Request) { var ( - creds Credentials + creds credentials userData Models.User session Models.Session expiresAt time.Time diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index f7b8151..999a2f2 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -62,6 +62,7 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/check", Auth.Check).Methods("GET") authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST") + authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST") authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 33d2069..685b774 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -58,6 +58,6 @@ type User struct { ConfirmPassword string `gorm:"-" json:"confirm_password"` AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"` - MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"message_expiry_default" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted + MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted } diff --git a/mobile/lib/components/custom_title_bar.dart b/mobile/lib/components/custom_title_bar.dart index 527b1d2..45cd96b 100644 --- a/mobile/lib/components/custom_title_bar.dart +++ b/mobile/lib/components/custom_title_bar.dart @@ -8,12 +8,14 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget { required this.showBack, this.rightHandButton, this.backgroundColor, + this.beforeBack, }) : super(key: key); final Text title; final bool showBack; final IconButton? rightHandButton; final Color? backgroundColor; + final Future Function()? beforeBack; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -59,7 +61,11 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget { Widget _backButton(BuildContext context) { return IconButton( - onPressed: (){ + onPressed: () { + if (beforeBack != null) { + beforeBack!().then((dynamic) => Navigator.pop(context)); + return; + } Navigator.pop(context); }, icon: Icon( diff --git a/mobile/lib/components/select_message_ttl.dart b/mobile/lib/components/select_message_ttl.dart new file mode 100644 index 0000000..c2be882 --- /dev/null +++ b/mobile/lib/components/select_message_ttl.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import '/components/custom_title_bar.dart'; + +const Map messageExpiryValues = { + 'no_expiry': 'No Expiry', + 'fifteen_min': '15 Minutes', + 'thirty_min': '30 Minutes', + 'one_hour': '1 Hour', + 'three_hour': '3 Hours', + 'six_hour': '6 Hours', + 'twelve_day': '12 Hours', + 'one_day': '1 Day', + 'three_day': '3 Days', +}; + +class SelectMessageTTL extends StatefulWidget { + const SelectMessageTTL({ + Key? key, + required this.widgetTitle, + required this.backCallback, + this.currentSelected, + }) : super(key: key); + + final String widgetTitle; + final Future Function(String messageExpiry) backCallback; + final String? currentSelected; + + @override + _SelectMessageTTLState createState() => _SelectMessageTTLState(); +} + +class _SelectMessageTTLState extends State { + String selectedExpiry = 'no_expiry'; + + @override + void initState() { + selectedExpiry = widget.currentSelected ?? 'no_expiry'; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomTitleBar( + title: Text( + widget.widgetTitle, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold + ) + ), + showBack: true, + backgroundColor: Colors.transparent, + beforeBack: () async { + widget.backCallback(selectedExpiry); + }, + ), + body: Padding( + padding: const EdgeInsets.only(top: 30), + child: list(), + ), + ); + } + + Widget list() { + return ListView.builder( + itemCount: messageExpiryValues.length, + shrinkWrap: true, + itemBuilder: (context, i) { + String key = messageExpiryValues.keys.elementAt(i); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setState(() { + selectedExpiry = key; + }); + }, + + child: Padding( + padding: const EdgeInsets.only(left: 30, right: 20, top: 8, bottom: 8), + child: Row( + children: [ + selectedExpiry == key ? + const Icon(Icons.check) : + const SizedBox(width: 20), + const SizedBox(width: 16), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + messageExpiryValues[key] ?? '', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.normal, + ), + ), + ) + ) + ], + ) + ) + ); + }, + ); + } +} diff --git a/mobile/lib/models/my_profile.dart b/mobile/lib/models/my_profile.dart index 07ec14a..526e668 100644 --- a/mobile/lib/models/my_profile.dart +++ b/mobile/lib/models/my_profile.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:Envelope/components/select_message_ttl.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:pointycastle/impl.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -10,7 +11,6 @@ import '/utils/encryption/crypto_utils.dart'; // TODO: Replace this with the prod url when server is deployed String defaultServerUrl = dotenv.env['SERVER_URL'] ?? 'http://192.168.1.5:8080'; - class MyProfile { String id; String username; @@ -18,6 +18,7 @@ class MyProfile { RSAPrivateKey? privateKey; RSAPublicKey? publicKey; DateTime? loggedInAt; + String messageExpiryDefault = 'no_expiry'; MyProfile({ required this.id, @@ -26,6 +27,7 @@ class MyProfile { this.privateKey, this.publicKey, this.loggedInAt, + required this.messageExpiryDefault, }); factory MyProfile._fromJson(Map json) { @@ -43,6 +45,7 @@ class MyProfile { privateKey: privateKey, publicKey: publicKey, loggedInAt: loggedInAt, + messageExpiryDefault: json['message_expiry_default'] ); } @@ -68,6 +71,7 @@ class MyProfile { CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) : null, 'logged_in_at': loggedInAt?.toIso8601String(), + 'message_expiry_default': messageExpiryDefault, }); } diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index be227a6..dd8e869 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -192,7 +192,6 @@ class _LoginWidgetState extends State { ); } - Future login() async { final resp = await http.post( await MyProfile.getServerUrl('api/v1/login'), diff --git a/mobile/lib/views/main/conversation/detail.dart b/mobile/lib/views/main/conversation/detail.dart index d0bbca2..572f855 100644 --- a/mobile/lib/views/main/conversation/detail.dart +++ b/mobile/lib/views/main/conversation/detail.dart @@ -22,7 +22,11 @@ class ConversationDetail extends StatefulWidget{ class _ConversationDetailState extends State { List messages = []; - MyProfile profile = MyProfile(id: '', username: ''); + MyProfile profile = MyProfile( + id: '', + username: '', + messageExpiryDefault: 'no_expiry', + ); TextEditingController msgController = TextEditingController(); diff --git a/mobile/lib/views/main/conversation/list.dart b/mobile/lib/views/main/conversation/list.dart index cabf6f0..62be875 100644 --- a/mobile/lib/views/main/conversation/list.dart +++ b/mobile/lib/views/main/conversation/list.dart @@ -47,7 +47,7 @@ class _ConversationListState extends State { children: [ TextField( decoration: const InputDecoration( - hintText: "Search...", + hintText: 'Search...', prefixIcon: Icon( Icons.search, size: 20 diff --git a/mobile/lib/views/main/conversation/list_item.dart b/mobile/lib/views/main/conversation/list_item.dart index 816b996..a94e900 100644 --- a/mobile/lib/views/main/conversation/list_item.dart +++ b/mobile/lib/views/main/conversation/list_item.dart @@ -6,7 +6,7 @@ import '/models/conversations.dart'; import '/views/main/conversation/detail.dart'; import '/utils/time.dart'; -class ConversationListItem extends StatefulWidget{ +class ConversationListItem extends StatefulWidget { final Conversation conversation; const ConversationListItem({ Key? key, @@ -33,70 +33,71 @@ class _ConversationListItemState extends State { ); })).then(onGoBack) : null; }, + child: Container( - padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10), - child: !loaded ? null : Row( + padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10), + child: !loaded ? null : Row( + children: [ + Expanded( + child: Row( children: [ + CustomCircleAvatar( + initials: widget.conversation.name[0].toUpperCase(), + imagePath: null, + ), + const SizedBox(width: 16), Expanded( - child: Row( - children: [ - CustomCircleAvatar( - initials: widget.conversation.name[0].toUpperCase(), - imagePath: null, - ), - const SizedBox(width: 16), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Container( - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.conversation.name, - style: const TextStyle(fontSize: 16) - ), - recentMessage != null ? - const SizedBox(height: 2) : - const SizedBox.shrink() - , - recentMessage != null ? - Text( - recentMessage!.data, - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, - ), - ) : - const SizedBox.shrink(), - ], - ), - ), - ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.conversation.name, + style: const TextStyle(fontSize: 16) + ), + recentMessage != null ? + const SizedBox(height: 2) : + const SizedBox.shrink() + , + recentMessage != null ? + Text( + recentMessage!.data, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold, + ), + ) : + const SizedBox.shrink(), + ], ), - recentMessage != null ? - Padding( - padding: const EdgeInsets.only(left: 10), - child: Text( - convertToAgo(recentMessage!.createdAt, short: true), - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - ), - ) - ): - const SizedBox.shrink(), - ], + ), ), ), + recentMessage != null ? + Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + convertToAgo(recentMessage!.createdAt, short: true), + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ) + ): + const SizedBox.shrink(), ], ), ), - ); + ], + ), + ), + ); } @override diff --git a/mobile/lib/views/main/home.dart b/mobile/lib/views/main/home.dart index b069fe6..b590798 100644 --- a/mobile/lib/views/main/home.dart +++ b/mobile/lib/views/main/home.dart @@ -24,8 +24,9 @@ class _HomeState extends State { List friends = []; List friendRequests = []; MyProfile profile = MyProfile( - id: '', - username: '', + id: '', + username: '', + messageExpiryDefault: 'no_expiry', ); bool isLoading = true; @@ -34,10 +35,11 @@ class _HomeState extends State { const ConversationList(conversations: [], friends: []), FriendList(friends: const [], friendRequests: const [], callback: () {}), Profile( - profile: MyProfile( - id: '', - username: '', - ) + profile: MyProfile( + id: '', + username: '', + messageExpiryDefault: 'no_expiry', + ) ), ]; diff --git a/mobile/lib/views/main/profile/profile.dart b/mobile/lib/views/main/profile/profile.dart index bedadcf..ae7df99 100644 --- a/mobile/lib/views/main/profile/profile.dart +++ b/mobile/lib/views/main/profile/profile.dart @@ -1,10 +1,14 @@ import 'dart:convert'; +import 'package:Envelope/components/flash_message.dart'; +import 'package:Envelope/utils/storage/session_cookie.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:sliding_up_panel/sliding_up_panel.dart'; +import 'package:http/http.dart' as http; +import '/components/select_message_ttl.dart'; import '/components/custom_circle_avatar.dart'; import '/components/custom_title_bar.dart'; import '/models/my_profile.dart'; @@ -144,7 +148,9 @@ class _ProfileState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const SizedBox(height: 5), + TextButton.icon( label: const Text( 'Disappearing Messages', @@ -160,10 +166,39 @@ class _ProfileState extends State { ) ), onPressed: () { - print('Disappearing Messages'); + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => SelectMessageTTL( + widgetTitle: 'Message Expiry', + currentSelected: widget.profile.messageExpiryDefault, + backCallback: (String messageExpiry) async { + widget.profile.messageExpiryDefault = messageExpiry; + + http.post( + await MyProfile.getServerUrl('api/v1/auth/message_expiry'), + headers: { + 'cookie': await getSessionCookie(), + }, + body: jsonEncode({ + 'message_expiry': messageExpiry, + }), + ).then((http.Response response) { + if (response.statusCode == 200) { + return; + } + + showMessage( + 'Could not change your default message expiry, please try again later.', + context, + ); + }); + }, + )) + ); } ), + const SizedBox(height: 5), + TextButton.icon( label: const Text( 'Server URL', @@ -180,12 +215,13 @@ class _ProfileState extends State { ), onPressed: () { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => ChangeServerUrl( - )) + MaterialPageRoute(builder: (context) => const ChangeServerUrl()) ); } ), + const SizedBox(height: 5), + TextButton.icon( label: const Text( 'Change Password',