diff --git a/finnow-api/README.md b/finnow-api/README.md new file mode 100644 index 0000000..d912819 --- /dev/null +++ b/finnow-api/README.md @@ -0,0 +1,15 @@ +# Finnow API + +The Finnow API is primarily implemented as an HTTP REST API using D, and the [handy-httpd](https://code.dlang.org/packages/handy-httpd) library. + +## Architecture + +This project is set up as a _modular monolith_, where the API as a whole is broken up into mostly-independent modules. Each module can be found under `source/`, like `source/auth` for example. + +Within each module, you'll usually find some of the following submodules: + +* `model.d` - Defines models for this module, often database entities. +* `data.d` - Defines the data access interfaces and associated types, so that other modules can interact with it. +* `data_impl_*.d` - A concrete implementation of a submodule's data access interfaces, often using a specific technology or platform. +* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework. +* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules. diff --git a/finnow-api/dub.json b/finnow-api/dub.json index 2d60fa4..80e5c5f 100644 --- a/finnow-api/dub.json +++ b/finnow-api/dub.json @@ -7,7 +7,9 @@ "asdf": "~>0.7.17", "botan": "~>1.13.6", "d2sqlite3": "~>1.0.0", - "handy-httpd": "~>8.4.0", + "handy-httpd": { + "path": "/home/andrew/Code/github-andrewlalis/handy-httpd" + }, "jwt": "~>0.4.0", "slf4d": "~>3.0.1" }, diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index 25b6df6..8312bc1 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -5,7 +5,6 @@ "botan": "1.13.6", "botan-math": "1.0.4", "d2sqlite3": "1.0.0", - "handy-httpd": "8.4.0", "httparsed": "1.2.1", "jwt": "0.4.0", "memutils": "1.0.10", diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 2a20544..4028daf 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -1,11 +1,11 @@ module account.api; import handy_httpd; -import asdf; import profile.service; import account.model; import money.currency; +import util.json; struct AccountResponse { ulong id; @@ -36,7 +36,7 @@ void handleGetAccounts(ref HttpRequestContext ctx) { auto ds = getProfileDataSource(ctx); auto accounts = ds.getAccountRepository().findAll() .map!(a => AccountResponse.of(a)); - ctx.response.writeBodyString(serializeToJson(accounts), "application/json"); + writeJsonBody(ctx, accounts); } void handleGetAccount(ref HttpRequestContext ctx) { @@ -44,7 +44,7 @@ void handleGetAccount(ref HttpRequestContext ctx) { auto ds = getProfileDataSource(ctx); auto account = ds.getAccountRepository().findById(accountId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); - ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json"); + writeJsonBody(ctx, AccountResponse.of(account)); } struct AccountCreationPayload { @@ -57,14 +57,7 @@ struct AccountCreationPayload { void handleCreateAccount(ref HttpRequestContext ctx) { auto ds = getProfileDataSource(ctx); - AccountCreationPayload payload; - try { - payload = deserialize!(AccountCreationPayload)(ctx.request.readBodyAsString()); - } catch (SerdeException e) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid account payload."); - return; - } + AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx); AccountType type = AccountType.fromId(payload.type); Currency currency = Currency.ofCode(payload.currency); Account account = ds.getAccountRepository().insert( @@ -74,7 +67,7 @@ void handleCreateAccount(ref HttpRequestContext ctx) { currency, payload.description ); - ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json"); + writeJsonBody(ctx, AccountResponse.of(account)); } void handleDeleteAccount(ref HttpRequestContext ctx) { diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 71703ef..9ab74d7 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -10,23 +10,24 @@ import handy_httpd.handlers.filtered_handler; */ PathHandler mapApiHandlers() { /// The base path to all API endpoints. - const API_PATH = "/api"; + const API_PATH = "/api"; PathHandler h = new PathHandler(); // Generic, public endpoints: h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus); h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions); - + // Auth endpoints: import auth.api; h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin); h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister); + h.addMapping(Method.GET, API_PATH ~ "/register/username-availability", &getUsernameAvailability); // Authenticated endpoints: PathHandler a = new PathHandler(); a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); - - import profile.api; + + import profile.api; a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles); a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile); /// URL path to a specific profile, with the :profile path parameter. @@ -34,25 +35,26 @@ PathHandler mapApiHandlers() { a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile); a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties); - import account.api; - a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); + import account.api; + a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount); a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount); a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount); - // Protect all authenticated paths with a token filter. + // Protect all authenticated paths with a token filter. import auth.service : TokenAuthenticationFilter, SECRET; HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET); h.addMapping(API_PATH ~ "/**", new FilteredRequestHandler( - a, - [tokenAuthenticationFilter] + a, + [tokenAuthenticationFilter] )); return h; } private void getStatus(ref HttpRequestContext ctx) { - ctx.response.writeBodyString("online"); + ctx.response.writeBodyString("online"); } -private void getOptions(ref HttpRequestContext ctx) {} +private void getOptions(ref HttpRequestContext ctx) { +} diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index 3aaff62..a182829 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -1,7 +1,12 @@ import handy_httpd; +import slf4d; +import slf4d.default_provider; import api_mapping; void main() { + auto provider = new DefaultProvider(true, Levels.INFO); + configureLoggingProvider(provider); + ServerConfig cfg; cfg.workerPoolSize = 5; cfg.port = 8080; diff --git a/finnow-api/source/attachment/data.d b/finnow-api/source/attachment/data.d new file mode 100644 index 0000000..9c69acd --- /dev/null +++ b/finnow-api/source/attachment/data.d @@ -0,0 +1,12 @@ +module attachment.data; + +import handy_httpd.components.optional; +import attachment.model; +import std.datetime; + +interface AttachmentRepository { + Optional!Attachment findById(ulong id); + Attachment[] findAllByLinkedEntity(string subquery, ulong entityId); + ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content); + void remove(ulong id); +} diff --git a/finnow-api/source/attachment/data_impl_sqlite.d b/finnow-api/source/attachment/data_impl_sqlite.d new file mode 100644 index 0000000..8bb7831 --- /dev/null +++ b/finnow-api/source/attachment/data_impl_sqlite.d @@ -0,0 +1,68 @@ +module attachment.data_impl_sqlite; + +import handy_httpd.components.optional; +import d2sqlite3; + +import attachment.model; +import attachment.data; +import util.sqlite; + +import std.datetime; +import std.format; + +class SqliteAttachmentRepository : AttachmentRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!Attachment findById(ulong id) { + return findOne( + db, + "SELECT * FROM attachment WHERE id = ?", + &parseAttachment, + id + ); + } + + Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) { + const query = format!"SELECT * FROM attachment WHERE id IN (%s)"(subquery); + return findAll(db, query, &parseAttachment, entityId); + } + + ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content) { + util.sqlite.update( + db, + q"SQL + INSERT INTO attachment + (uploaded_at, filename, content_type, size, content) + VALUES (?, ?, ?, ?, ?) +SQL", + uploadedAt.toISOExtString(), + filename, + contentType, + cast(ulong) content.length, + content + ); + return db.lastInsertRowid(); + } + + void remove(ulong id) { + util.sqlite.update( + db, + "DELETE FROM attachment WHERE id = ?", + id + ); + } + + static Attachment parseAttachment(Row row) { + return Attachment( + row.peek!ulong(0), + SysTime.fromISOExtString(row.peek!string(1), UTC()), + row.peek!string(2), + row.peek!string(3), + row.peek!ulong(4), + row.peek!(ubyte[])(5) + ); + } +} diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d index f5f5d8b..7c20f4f 100644 --- a/finnow-api/source/auth/api.d +++ b/finnow-api/source/auth/api.d @@ -4,20 +4,15 @@ module auth.api; import handy_httpd; import handy_httpd.components.optional; import slf4d; -import asdf; import auth.model; import auth.data; import auth.service; import auth.data_impl_fs; +import util.json; void postLogin(ref HttpRequestContext ctx) { - LoginCredentials loginCredentials; - try { - loginCredentials = deserialize!(LoginCredentials)(ctx.request.readBodyAsString()); - } catch (Exception e) { - ctx.response.status = HttpStatus.BAD_REQUEST; - } + LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx); if (!validateUsername(loginCredentials.username)) { ctx.response.status = HttpStatus.UNAUTHORIZED; return; @@ -26,27 +21,32 @@ void postLogin(ref HttpRequestContext ctx) { Optional!User optionalUser = userRepo.findByUsername(loginCredentials.username); if (optionalUser.isNull) { ctx.response.status = HttpStatus.UNAUTHORIZED; - return; + return; } import botan.passhash.bcrypt : checkBcrypt; + if (!checkBcrypt(loginCredentials.password, optionalUser.value.passwordHash)) { ctx.response.status = HttpStatus.UNAUTHORIZED; return; } string token = generateAccessToken(optionalUser.value); - ctx.response.status = HttpStatus.OK; - TokenResponse resp = TokenResponse(token); - ctx.response.writeBodyString(serializeToJson(resp), "application/json"); + writeJsonBody(ctx, TokenResponse(token)); +} + +void getUsernameAvailability(ref HttpRequestContext ctx) { + Optional!string username = ctx.request.queryParams.getFirst("username"); + if (username.isNull) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Missing username parameter."); + return; + } + UserRepository userRepo = new FileSystemUserRepository(); + bool available = userRepo.findByUsername(username.value).isNull; + writeJsonBody(ctx, UsernameAvailabilityResponse(available)); } void postRegister(ref HttpRequestContext ctx) { - RegistrationData registrationData; - try { - registrationData = deserialize!(RegistrationData)(ctx.request.readBodyAsString()); - } catch (Exception e) { - ctx.response.status = HttpStatus.BAD_REQUEST; - return; - } + RegistrationData registrationData = readJsonPayload!RegistrationData(ctx); if (!validateUsername(registrationData.username)) { ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.writeBodyString("Invalid username."); @@ -66,10 +66,13 @@ void postRegister(ref HttpRequestContext ctx) { import botan.passhash.bcrypt : generateBcrypt; import botan.rng.auto_rng; + RandomNumberGenerator rng = new AutoSeededRNG(); string passwordHash = generateBcrypt(registrationData.password, rng, 12); - userRepo.createUser(registrationData.username, passwordHash); + User user = userRepo.createUser(registrationData.username, passwordHash); infoF!"Created user: %s"(registrationData.username); + string token = generateAccessToken(user); + writeJsonBody(ctx, TokenResponse(token)); } void getMyUser(ref HttpRequestContext ctx) { diff --git a/finnow-api/source/auth/data.d b/finnow-api/source/auth/data.d index 086b8bd..acf86a0 100644 --- a/finnow-api/source/auth/data.d +++ b/finnow-api/source/auth/data.d @@ -14,6 +14,10 @@ struct TokenResponse { string token; } +struct UsernameAvailabilityResponse { + bool available; +} + struct RegistrationData { string username; string password; diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d index 27c9001..6ae4682 100644 --- a/finnow-api/source/profile/api.d +++ b/finnow-api/source/profile/api.d @@ -11,6 +11,7 @@ import profile.data; import profile.data_impl_sqlite; import auth.model; import auth.service; +import util.json; void handleCreateNewProfile(ref HttpRequestContext ctx) { JSONValue obj = ctx.request.readBodyAsJson(); @@ -29,7 +30,7 @@ void handleGetProfiles(ref HttpRequestContext ctx) { AuthContext auth = getAuthContext(ctx); ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); Profile[] profiles = profileRepo.findAll(); - ctx.response.writeBodyString(serializeToJson(profiles), "application/json"); + writeJsonBody(ctx, profiles); } void handleDeleteProfile(ref HttpRequestContext ctx) { @@ -50,5 +51,5 @@ void handleGetProperties(ref HttpRequestContext ctx) { ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile); auto propsRepo = ds.getPropertiesRepository(); ProfileProperty[] props = propsRepo.findAll(); - ctx.response.writeBodyString(serializeToJson(props), "application/json"); + writeJsonBody(ctx, props); } \ No newline at end of file diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 7e6ef1f..678be4f 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -11,6 +11,7 @@ interface ProfileRepository { ProfileDataSource getDataSource(in Profile profile); } +/// Repository for accessing the properties of a profile. interface PropertiesRepository { Optional!string findProperty(string propertyName); void setProperty(string name, string value); diff --git a/finnow-api/source/util/json.d b/finnow-api/source/util/json.d new file mode 100644 index 0000000..5c71c9a --- /dev/null +++ b/finnow-api/source/util/json.d @@ -0,0 +1,41 @@ +/// Utilities for reading and writing JSON in HTTP request contexts. +module util.json; + +import handy_httpd; +import slf4d; +import asdf; + +/** + * Reads a JSON payload into a type T. Throws an `HttpStatusException` if + * the data cannot be read or converted to the given type, with a 400 BAD + * REQUEST status. + * Params: + * ctx = The request context to read from. + * Returns: The data that was read. + */ +T readJsonPayload(T)(ref HttpRequestContext ctx) { + try { + string requestBody = ctx.request.readBodyAsString(); + return deserialize!T(requestBody); + } catch (SerdeException e) { + debug_("Got an exception while deserializing a request body.", e); + throw new HttpStatusException(HttpStatus.BAD_REQUEST); + } +} + +/** + * Writes data of type T to a JSON response body. Throws an `HttpStatusException` + * with status 501 INTERNAL SERVER ERROR if serialization fails. + * Params: + * ctx = The request context to write to. + * data = The data to write. + */ +void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) { + try { + string jsonStr = serializeToJson(data); + ctx.response.writeBodyString(jsonStr, "application/json"); + } catch (SerdeException e) { + debug_("Exception while serializing a response body.", e); + throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/flutter_app/lib/api/auth.dart b/flutter_app/lib/api/auth.dart new file mode 100644 index 0000000..540073c --- /dev/null +++ b/flutter_app/lib/api/auth.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class LoginCredentials { + final String username; + final String password; + const LoginCredentials(this.username, this.password); + + Map toJson() { + return { + 'username': username, + 'password': password, + }; + } +} + +class TokenResponse { + final String token; + const TokenResponse(this.token); + + factory TokenResponse.fromJson(Map json) { + return switch (json) { + {'token': String token} => TokenResponse(token), + _ => throw const FormatException('Invalid token response format.'), + }; + } +} + +Future postLogin(LoginCredentials credentials) async { + final http.Response response = await http.post( + Uri.parse('http://localhost:8080/api/login'), + body: jsonEncode(credentials.toJson()), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final TokenResponse obj = TokenResponse.fromJson(data); + return obj.token; + } else { + throw Exception('Failed to log in.'); + } +} + +Future getUsernameAvailability(String username) async { + final http.Response response = await http.get( + Uri.parse('http://localhost:8080/api/register/username-availability?username=$username'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return data['available'] as bool; + } else { + return false; + } +} + +Future postRegister(LoginCredentials credentials) async { + final bodyContent = jsonEncode(credentials.toJson()); + final http.Response response = await http.post( + Uri.parse('http://localhost:8080/api/register'), + body: bodyContent, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Content-Length': bodyContent.length.toString() + } + ); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return TokenResponse.fromJson(data).token; + } else { + print(response); + throw Exception('Registration failed.'); + } +} diff --git a/flutter_app/lib/api/main.dart b/flutter_app/lib/api/main.dart new file mode 100644 index 0000000..2c00712 --- /dev/null +++ b/flutter_app/lib/api/main.dart @@ -0,0 +1,4 @@ + +class FinnowApi { + +} \ No newline at end of file diff --git a/flutter_app/lib/api/profile.dart b/flutter_app/lib/api/profile.dart new file mode 100644 index 0000000..9dedfda --- /dev/null +++ b/flutter_app/lib/api/profile.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class Profile { + final String name; + const Profile(this.name); + + factory Profile.fromJson(Map json) { + return switch(json) { + {'name': String name} => Profile(name), + _ => throw const FormatException('Invalid profile object.') + }; + } +} + +Future> getProfiles(String token) async { + final http.Response response = await http.get( + Uri.parse('http://localhost:8080/api/profiles'), + headers: { + 'Authorization': 'Bearer $token' + } + ); + if (response.statusCode == 200) { + if (jsonDecode(response.body) == null) return []; // Workaround for bad array serialization in the API. + final data = jsonDecode(response.body) as List; + return data.map((obj) => Profile.fromJson(obj)).toList(); + } else { + throw Exception('Failed to get profiles.'); + } +} diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart new file mode 100644 index 0000000..14dfa33 --- /dev/null +++ b/flutter_app/lib/app.dart @@ -0,0 +1,37 @@ +import 'package:finnow_app/main.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:watch_it/watch_it.dart'; + +import 'auth/model.dart'; + +/// The main Finnow application. +class FinnowApp extends StatelessWidget with WatchItMixin { + const FinnowApp({super.key}); + + @override + Widget build(BuildContext context) { + final router = getIt(); + + // We add a top-level listener for anytime the authentication model changes. + // If it does, we need to refresh navigation. + registerChangeNotifierHandler(handler:(context, AuthenticationModel newValue, cancel) { + if (newValue.state.authenticated()) { + router.replace('/profiles'); + } else { + while (router.canPop()) { + router.pop(); + } + router.pushReplacement('/login'); + } + },); + + return MaterialApp.router( + routerConfig: router, + title: 'Finnow', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + )); + } +} diff --git a/flutter_app/lib/auth/model.dart b/flutter_app/lib/auth/model.dart new file mode 100644 index 0000000..b340df7 --- /dev/null +++ b/flutter_app/lib/auth/model.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; + +sealed class AuthenticationState { + const AuthenticationState(); + bool authenticated(); +} + +class Unauthenticated extends AuthenticationState { + @override + bool authenticated() { + return false; + } +} + +class Authenticated extends AuthenticationState { + final String token; + final String username; + const Authenticated(this.token, this.username); + + @override + bool authenticated() { + return true; + } +} + +class AuthenticationModel extends ChangeNotifier { + AuthenticationState _state = Unauthenticated(); + + AuthenticationState get state => _state; + + set state(AuthenticationState newState) { + _state = newState; + notifyListeners(); + } +} + +String? usernameValidator(String? value) { + if (value == null || value.trim().length < 3) { + return 'Please enter a valid username.'; + } + return null; +} + +String? passwordValidator(String? value) { + if (value == null || value.length < 8) { + return 'Please enter a valid password.'; + } + return null; +} diff --git a/flutter_app/lib/components/form_label.dart b/flutter_app/lib/components/form_label.dart new file mode 100644 index 0000000..030bef7 --- /dev/null +++ b/flutter_app/lib/components/form_label.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +/// A simple left-aligned, bold text widget intended to be placed above form +/// input widgets. +class FormLabel extends StatelessWidget { + final String text; + const FormLabel(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold))); + } +} diff --git a/flutter_app/lib/components/title_text.dart b/flutter_app/lib/components/title_text.dart new file mode 100644 index 0000000..8dc6629 --- /dev/null +++ b/flutter_app/lib/components/title_text.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +class TitleText extends StatelessWidget { + final String text; + const TitleText(this.text, {super.key}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold), + ); + } +} diff --git a/flutter_app/lib/login_page.dart b/flutter_app/lib/login_page.dart index 44bd65b..330d1ce 100644 --- a/flutter_app/lib/login_page.dart +++ b/flutter_app/lib/login_page.dart @@ -1,4 +1,11 @@ +import 'package:finnow_app/auth/model.dart'; +import 'package:finnow_app/components/form_label.dart'; +import 'package:finnow_app/components/title_text.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'api/auth.dart'; +import 'main.dart'; class LoginPage extends StatelessWidget { const LoginPage({super.key}); @@ -36,49 +43,35 @@ class _LoginFormState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('Username'), + const TitleText('Login to Finnow'), + const SizedBox(height: 20), + const FormLabel('Username'), TextFormField( controller: usernameTextController, decoration: const InputDecoration( hintText: 'Enter username', border: OutlineInputBorder()), - validator: (value) { - if (value == null || value.trim().length < 3) { - return 'Please enter a valid username.'; - } - return null; - }, + validator: usernameValidator, ), const SizedBox(height: 10), - const Text('Password'), + const FormLabel('Password'), TextFormField( controller: passwordTextController, obscureText: true, decoration: const InputDecoration( hintText: 'Enter password', border: OutlineInputBorder()), - validator: (value) { - if (value == null || value.length < 8) { - return 'Please enter a valid password.'; - } - return null; - }, + validator: passwordValidator, ), const SizedBox(height: 10), - TextButton( - onPressed: () { - if (formKey.currentState!.validate()) { - print(usernameTextController.text); - print(passwordTextController.text); - } else { - setState(() => loginFailed = true); - Future.delayed(const Duration(seconds: 3), - () => setState(() => loginFailed = false)); - } - }, - child: const Text('Login')), - if (loginFailed) + if (!loginFailed) + TextButton(onPressed: onLoginPressed, child: const Text('Login')), + if (loginFailed) ...[ + const SizedBox(height: 10), TextButton( - onPressed: () {}, child: const Text('Forgot password?')), - TextButton(onPressed: () {}, child: const Text('Create an Account')) + onPressed: () {}, child: const Text('Forgot password?')) + ], + TextButton( + onPressed: onCreateAccountPressed, + child: const Text('Create an Account')) ], )); } @@ -89,4 +82,30 @@ class _LoginFormState extends State { passwordTextController.dispose(); super.dispose(); } + + void onLoginPressed() async { + if (formKey.currentState!.validate()) { + final credentials = LoginCredentials( + usernameTextController.text, passwordTextController.text); + try { + String token = await postLogin(credentials); + getIt().state = Authenticated(token, credentials.username); + } catch (e) { + print(e); + onLoginAttemptFailed(); + } + } else { + onLoginAttemptFailed(); + } + } + + void onLoginAttemptFailed() async { + setState(() => loginFailed = true); + Future.delayed( + const Duration(seconds: 3), () => setState(() => loginFailed = false)); + } + + void onCreateAccountPressed() async { + getIt().go('/register'); + } } diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index e139f1e..1d19d6e 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -1,70 +1,64 @@ -import 'package:finnow_app/login_page.dart'; +import 'package:finnow_app/api/main.dart'; +import 'package:finnow_app/auth/model.dart'; import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:watch_it/watch_it.dart'; +import 'app.dart'; +import 'login_page.dart'; +import 'profiles_page.dart'; +import 'register_page.dart'; + +final getIt = GetIt.instance; void main() { + setup(); runApp(const FinnowApp()); } -class FinnowApp extends StatelessWidget { - const FinnowApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Finnow', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - useMaterial3: true, - ), - home: const LoginPage(), - ); - } +void setup() { + getIt.registerSingleton(FinnowApi()); + getIt.registerSingleton(AuthenticationModel()); + getIt.registerSingleton(getRouterConfig()); } -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', +GoRouter getRouterConfig() { + return GoRouter(routes: [ + GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + GoRoute( + path: '/register', builder: (context, state) => const RegisterPage()), + // Once a user has logged in, they're directed to a scaffold for the /profiles page. + ShellRoute( + builder: (context, state, child) => Scaffold( + body: child, + appBar: AppBar( + title: const Text('Finnow'), + backgroundColor: Colors.grey, + actions: [ + TextButton( + onPressed: () => getIt().state = + Unauthenticated(), + child: const Text('Logout')) + ], + ), ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], - ), - ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. - ); - } + redirect: (context, state) { + final bool authenticated = + getIt().state.authenticated(); + return authenticated ? null : '/login'; + }, + routes: [ + GoRoute( + path: '/', + redirect: (context, state) { + final bool authenticated = + getIt().state.authenticated(); + return authenticated ? '/profiles' : '/login'; + }), + GoRoute( + path: '/profiles', + builder: (context, state) => const ProfilesPage(), + ) + ]), + ]); } diff --git a/flutter_app/lib/profiles_page.dart b/flutter_app/lib/profiles_page.dart new file mode 100644 index 0000000..47b94b9 --- /dev/null +++ b/flutter_app/lib/profiles_page.dart @@ -0,0 +1,60 @@ +import 'package:finnow_app/auth/model.dart'; +import 'package:flutter/material.dart'; + +import 'api/profile.dart'; +import 'main.dart'; + +class ProfilesPage extends StatelessWidget { + const ProfilesPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: const Padding( + padding: EdgeInsets.all(8.0), + child: Column(children: [ + Text('Profiles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)), + SizedBox(height: 10), + Expanded(child: _ProfilesListView()), + ])), + floatingActionButton: FloatingActionButton( + onPressed: () => print('pressed'), + child: const Icon(Icons.add) + ), + ); + } +} + +class _ProfilesListView extends StatefulWidget { + const _ProfilesListView(); + + @override + State<_ProfilesListView> createState() => __ProfilesListViewState(); +} + +class __ProfilesListViewState extends State<_ProfilesListView> { + List profiles = List.empty(); + + @override + Widget build(BuildContext context) { + return ListView( + scrollDirection: Axis.vertical, + children: profiles.map((p) => Text('Profile: ${p.name}')).toList(), + ); + } + + @override + void initState() { + super.initState(); + refreshProfiles(); + } + + void refreshProfiles() async { + setState(() => profiles = List.empty()); + final authState = getIt().state; + if (authState is Authenticated) { + final List latestProfiles = await getProfiles(authState.token); + setState(() => profiles = latestProfiles); + } + } +} diff --git a/flutter_app/lib/register_page.dart b/flutter_app/lib/register_page.dart new file mode 100644 index 0000000..4419a19 --- /dev/null +++ b/flutter_app/lib/register_page.dart @@ -0,0 +1,139 @@ +import 'package:finnow_app/api/auth.dart'; +import 'package:finnow_app/auth/model.dart'; +import 'package:finnow_app/components/form_label.dart'; +import 'package:finnow_app/components/title_text.dart'; +import 'package:finnow_app/main.dart'; +import 'package:finnow_app/util/debouncer.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class RegisterPage extends StatelessWidget { + const RegisterPage({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 300.0), + padding: const EdgeInsets.all(10), + child: const RegisterForm()))); + } +} + +class RegisterForm extends StatefulWidget { + const RegisterForm({super.key}); + + @override + State createState() => _RegisterFormState(); +} + +class _RegisterFormState extends State { + final formKey = GlobalKey(); + final usernameTextController = TextEditingController(); + final passwordTextController = TextEditingController(); + var loading = false; + final usernameAvailabilityDebouncer = Debouncer(); + bool? usernameAvailable; + var canCreateAccount = false; + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const TitleText('Create a Finnow Account'), + const SizedBox(height: 20), + const FormLabel('Username'), + TextFormField( + controller: usernameTextController, + onChanged: (s) { + formValuesUpdated(); + usernameAvailabilityDebouncer + .run(() => checkUsernameAvailability()); + }, + decoration: const InputDecoration( + hintText: 'Enter username', border: OutlineInputBorder()), + validator: usernameValidator, + ), + if (usernameAvailable != null && usernameAvailable == true) ...[ + const Text('Username is available.', + style: TextStyle(color: Colors.green)) + ], + if (usernameAvailable != null && usernameAvailable == false) ...[ + const Text('Username is taken.', + style: TextStyle(color: Colors.red)) + ], + const SizedBox( + height: 10, + ), + const FormLabel('Password'), + TextFormField( + controller: passwordTextController, + onChanged: (s) => formValuesUpdated(), + obscureText: true, + decoration: const InputDecoration( + hintText: 'Enter password', border: OutlineInputBorder()), + validator: passwordValidator), + const SizedBox(height: 10), + TextButton( + onPressed: loading || !canCreateAccount ? null : createAccount, + child: const Text('Create Account'), + ), + const SizedBox( + height: 10, + ), + TextButton( + onPressed: () { + getIt().replace('/login'); + }, + child: const Text('Back to Login')) + ])); + } + + @override + void dispose() { + usernameTextController.dispose(); + passwordTextController.dispose(); + super.dispose(); + } + + void formValuesUpdated() { + final usernameValidation = usernameValidator(usernameTextController.text); + final passwordValidation = passwordValidator(passwordTextController.text); + setState(() { + canCreateAccount = + usernameValidation == null && passwordValidation == null; + }); + } + + void checkUsernameAvailability() async { + final usernameText = usernameTextController.text; + // Set usernameAvailable to null if the username is invalid. + if (usernameValidator(usernameText) != null) { + setState(() => usernameAvailable = null); + return; + } + // Otherwise, there's a valid username, so check if it's available. + final available = await getUsernameAvailability(usernameText); + setState(() => usernameAvailable = available); + } + + void createAccount() async { + print('Creating account...'); + if (formKey.currentState!.validate()) { + setState(() => loading = true); + final credentials = LoginCredentials( + usernameTextController.text, passwordTextController.text); + try { + final token = await postRegister(credentials); + getIt().state = + Authenticated(token, credentials.username); + } catch (e) { + print(e); + } finally { + setState(() => loading = false); + } + } + } +} diff --git a/flutter_app/lib/util/debouncer.dart b/flutter_app/lib/util/debouncer.dart new file mode 100644 index 0000000..ec6abc0 --- /dev/null +++ b/flutter_app/lib/util/debouncer.dart @@ -0,0 +1,18 @@ +import 'dart:async'; + +class Debouncer { + final Duration delay; + Timer? _timer; + + Debouncer({this.delay = const Duration(milliseconds: 300)}); + + run(void Function() action) { + _timer?.cancel(); + _timer = Timer(delay, action); + } + + void dispose() { + _timer?.cancel(); + _timer = null; + } +} \ No newline at end of file diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index 6cb5f72..4cf6ffa 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -66,15 +66,60 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "4.0.0" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functional_listener: + dependency: transitive + description: + name: functional_listener + sha256: "026d1bd4f66367f11d9ec9f1f1ddb42b89e4484b356972c76d983266cf82f33f" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d380de0355788c5c784fe9f81b43fc833b903991c25ecc4e2a416a67faefa722 + url: "https://pub.dev" + source: hosted + version: "14.2.2" + http: + dependency: "direct main" + description: + name: http + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" leak_tracker: dependency: transitive description: @@ -103,10 +148,18 @@ packages: dependency: transitive description: name: lints - sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" matcher: dependency: transitive description: @@ -192,6 +245,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" vector_math: dependency: transitive description: @@ -208,6 +269,22 @@ packages: url: "https://pub.dev" source: hosted version: "14.2.1" + watch_it: + dependency: "direct main" + description: + name: watch_it + sha256: a01a9e8292c040de82670f28f8a7d35315115a22f3674d2c4a8fd811fd1ac0ab + url: "https://pub.dev" + source: hosted + version: "1.4.2" + web: + dependency: transitive + description: + name: web + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + url: "https://pub.dev" + source: hosted + version: "1.0.0" sdks: dart: ">=3.4.4 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index e0fe77f..b130988 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -35,6 +35,10 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.6 + http: ^1.2.2 + go_router: ^14.2.2 + get_it: ^7.7.0 + watch_it: ^1.4.2 dev_dependencies: flutter_test: @@ -45,7 +49,7 @@ dev_dependencies: # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^3.0.0 + flutter_lints: ^4.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec