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',