diff --git a/finnow-api/README.md b/finnow-api/README.md index d912819..dea9209 100644 --- a/finnow-api/README.md +++ b/finnow-api/README.md @@ -8,8 +8,8 @@ This project is set up as a _modular monolith_, where the API as a whole is brok Within each module, you'll usually find some of the following submodules: -* `model.d` - Defines models for this module, often database entities. +* `model.d` - Defines models for this module, often database entities. Unless there's a **very** good reason for it, all entity attributes are marked as `const`. Entities are not mutable. They simply represent a view of what's in the database. * `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. +* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework, as well as data transfer objects that the API endpoints may consume or produce. +* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules. This is where transactional business logic lives. diff --git a/finnow-api/bruno-api/Finnow/Accounts/Create Account.bru b/finnow-api/bruno-api/Finnow/Accounts/Create Account.bru new file mode 100644 index 0000000..d24af88 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Accounts/Create Account.bru @@ -0,0 +1,29 @@ +meta { + name: Create Account + type: http + seq: 3 +} + +post { + url: {{base_url}}/profiles/:profile/accounts + body: json + auth: bearer +} + +params:path { + profile: {{current_profile}} +} + +auth:bearer { + token: {{access_token}} +} + +body:json { + { + "type": "CHECKING", + "numberSuffix": "1234", + "name": "Testing Checking", + "currency": "USD", + "description": "A test account." + } +} diff --git a/finnow-api/bruno-api/Finnow/Accounts/Delete Account.bru b/finnow-api/bruno-api/Finnow/Accounts/Delete Account.bru new file mode 100644 index 0000000..d4b53c6 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Accounts/Delete Account.bru @@ -0,0 +1,20 @@ +meta { + name: Delete Account + type: http + seq: 4 +} + +delete { + url: {{base_url}}/profiles/:profile/accounts/:accountId + body: none + auth: bearer +} + +params:path { + profile: {{current_profile}} + accountId: 1 +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/Accounts/Get Account.bru b/finnow-api/bruno-api/Finnow/Accounts/Get Account.bru new file mode 100644 index 0000000..96ec9fc --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Accounts/Get Account.bru @@ -0,0 +1,20 @@ +meta { + name: Get Account + type: http + seq: 2 +} + +get { + url: {{base_url}}/profiles/:profile/accounts/:accountId + body: none + auth: bearer +} + +params:path { + profile: {{current_profile}} + accountId: 1 +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/Accounts/Get Accounts.bru b/finnow-api/bruno-api/Finnow/Accounts/Get Accounts.bru new file mode 100644 index 0000000..7193b88 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Accounts/Get Accounts.bru @@ -0,0 +1,19 @@ +meta { + name: Get Accounts + type: http + seq: 1 +} + +get { + url: {{base_url}}/profiles/:name/accounts + body: none + auth: bearer +} + +params:path { + name: {{profile}} +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/Auth/Delete My User.bru b/finnow-api/bruno-api/Finnow/Auth/Delete My User.bru new file mode 100644 index 0000000..8340ab8 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Auth/Delete My User.bru @@ -0,0 +1,11 @@ +meta { + name: Delete My User + type: http + seq: 4 +} + +delete { + url: {{base_url}}/me + body: none + auth: inherit +} diff --git a/finnow-api/bruno-api/Finnow/Auth/Login.bru b/finnow-api/bruno-api/Finnow/Auth/Login.bru new file mode 100644 index 0000000..24f6745 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Auth/Login.bru @@ -0,0 +1,19 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{base_url}}/login + body: json + auth: none +} + +body:json { + { + "username": "{{username}}", + "password": "{{password}}" + } + +} diff --git a/finnow-api/bruno-api/Finnow/Auth/My User.bru b/finnow-api/bruno-api/Finnow/Auth/My User.bru new file mode 100644 index 0000000..66e5f38 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Auth/My User.bru @@ -0,0 +1,11 @@ +meta { + name: My User + type: http + seq: 3 +} + +get { + url: {{base_url}}/me + body: none + auth: inherit +} diff --git a/finnow-api/bruno-api/Finnow/Auth/Register.bru b/finnow-api/bruno-api/Finnow/Auth/Register.bru new file mode 100644 index 0000000..d6b99f3 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Auth/Register.bru @@ -0,0 +1,19 @@ +meta { + name: Register + type: http + seq: 2 +} + +post { + url: {{base_url}}/register + body: json + auth: none +} + +body:json { + { + "username": "testuser", + "password": "testpass" + } + +} diff --git a/finnow-api/bruno-api/Finnow/Generate Sample Data.bru b/finnow-api/bruno-api/Finnow/Generate Sample Data.bru new file mode 100644 index 0000000..521596a --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Generate Sample Data.bru @@ -0,0 +1,18 @@ +meta { + name: Generate Sample Data + type: http + seq: 1 +} + +post { + url: {{base_url}}/sample-data + body: none + auth: none +} + +docs { + Wipes and re-generates a set of sample accounts in the Finnow API. Only available in the development version. + + The response is returned immediately, but the sample data generation takes a few seconds to complete. + +} diff --git a/finnow-api/bruno-api/Finnow/Profiles/Create Profile.bru b/finnow-api/bruno-api/Finnow/Profiles/Create Profile.bru new file mode 100644 index 0000000..1dee22f --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Profiles/Create Profile.bru @@ -0,0 +1,21 @@ +meta { + name: Create Profile + type: http + seq: 1 +} + +post { + url: {{base_url}}/profiles + body: json + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} + +body:json { + { + "name": "{{current_profile}}" + } +} diff --git a/finnow-api/bruno-api/Finnow/Profiles/Delete Profile.bru b/finnow-api/bruno-api/Finnow/Profiles/Delete Profile.bru new file mode 100644 index 0000000..a2d03bd --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Profiles/Delete Profile.bru @@ -0,0 +1,19 @@ +meta { + name: Delete Profile + type: http + seq: 2 +} + +delete { + url: {{base_url}}/profiles/:name + body: none + auth: bearer +} + +params:path { + name: {{profile}} +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/Profiles/Get Profiles.bru b/finnow-api/bruno-api/Finnow/Profiles/Get Profiles.bru new file mode 100644 index 0000000..6f5425f --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Profiles/Get Profiles.bru @@ -0,0 +1,15 @@ +meta { + name: Get Profiles + type: http + seq: 3 +} + +get { + url: {{base_url}}/profiles + body: none + auth: bearer +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/Profiles/Get Properties.bru b/finnow-api/bruno-api/Finnow/Profiles/Get Properties.bru new file mode 100644 index 0000000..390e954 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Profiles/Get Properties.bru @@ -0,0 +1,19 @@ +meta { + name: Get Properties + type: http + seq: 4 +} + +get { + url: {{base_url}}/profiles/:name/properties + body: none + auth: bearer +} + +params:path { + name: {{profile}} +} + +auth:bearer { + token: {{access_token}} +} diff --git a/finnow-api/bruno-api/Finnow/bruno.json b/finnow-api/bruno-api/Finnow/bruno.json new file mode 100644 index 0000000..48996d4 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Finnow", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/finnow-api/bruno-api/Finnow/collection.bru b/finnow-api/bruno-api/Finnow/collection.bru new file mode 100644 index 0000000..93ee534 --- /dev/null +++ b/finnow-api/bruno-api/Finnow/collection.bru @@ -0,0 +1,59 @@ +auth { + mode: bearer +} + +auth:bearer { + token: {{access_token}} +} + +script:pre-request { + const axios = require("axios"); + const baseUrl = bru.getEnvVar("base_url"); + + + // Before each request, check and refresh access_token if needed. + if (req.getAuthMode() === "bearer") { + await checkAuth(); + } + + async function checkAuth() { + const access_token = bru.getEnvVar("access_token"); + if (!access_token || access_token === "null") { + console.info("No access token is present, refreshing..."); + await refreshAuth(); + return; + } + // Access token exists, check that it's still valid. + try { + const resp = await axios.get(baseUrl + "/me", { + headers: {"Authorization": "Bearer " + access_token} + }); + if (resp.status === 200) { + return; + } else if (resp.status === 401) { + await refreshAuth(); + } else { + throw resp; + } + } catch (error) { + console.error(error); + } + } + + async function refreshAuth() { + const payload = { + username: bru.getEnvVar("username"), + password: bru.getEnvVar("password") + }; + const resp = await axios.post(baseUrl + "/login", payload, { + headers: {"Content-Type": "application/json"} + }); + if (resp.status === 200) { + bru.setEnvVar("access_token", resp.data.token); + console.info("Refreshed access token."); + } else { + throw resp; + } + } + +} diff --git a/finnow-api/bruno-api/Finnow/environments/Finnow-Local.bru b/finnow-api/bruno-api/Finnow/environments/Finnow-Local.bru new file mode 100644 index 0000000..6a4bb8e --- /dev/null +++ b/finnow-api/bruno-api/Finnow/environments/Finnow-Local.bru @@ -0,0 +1,9 @@ +vars { + username: testuser0 + password: testpass + profile: test-profile-0 + base_url: http://localhost:8080/api +} +vars:secret [ + access_token +] diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index 8312bc1..c813ebd 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -5,6 +5,7 @@ "botan": "1.13.6", "botan-math": "1.0.4", "d2sqlite3": "1.0.0", + "handy-httpd": {"path":"../../../github-andrewlalis/handy-httpd"}, "httparsed": "1.2.1", "jwt": "0.4.0", "memutils": "1.0.10", diff --git a/finnow-api/schema.sql b/finnow-api/schema.sql index 4c0415c..b6888b0 100644 --- a/finnow-api/schema.sql +++ b/finnow-api/schema.sql @@ -49,6 +49,7 @@ CREATE TABLE transaction_category ( id INTEGER PRIMARY KEY, parent_id INTEGER, name TEXT NOT NULL UNIQUE, + description TEXT, color TEXT NOT NULL DEFAULT 'FFFFFF', CONSTRAINT fk_transaction_category_parent FOREIGN KEY (parent_id) REFERENCES transaction_category(id) diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 4028daf..acd774b 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -1,12 +1,17 @@ +/** + * This module defines the API endpoints for dealing with Accounts directly, + * including any data-transfer objects that are needed. + */ module account.api; import handy_httpd; import profile.service; import account.model; -import money.currency; +import util.money; import util.json; +/// The data the API provides for an Account entity. struct AccountResponse { ulong id; string createdAt; @@ -33,9 +38,10 @@ struct AccountResponse { void handleGetAccounts(ref HttpRequestContext ctx) { import std.algorithm; + import std.array; auto ds = getProfileDataSource(ctx); auto accounts = ds.getAccountRepository().findAll() - .map!(a => AccountResponse.of(a)); + .map!(a => AccountResponse.of(a)).array; writeJsonBody(ctx, accounts); } @@ -47,6 +53,7 @@ void handleGetAccount(ref HttpRequestContext ctx) { writeJsonBody(ctx, AccountResponse.of(account)); } +// The data provided by a user to create a new account. struct AccountCreationPayload { string type; string numberSuffix; @@ -58,6 +65,7 @@ struct AccountCreationPayload { void handleCreateAccount(ref HttpRequestContext ctx) { auto ds = getProfileDataSource(ctx); AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx); + // TODO: Validate the account creation payload. AccountType type = AccountType.fromId(payload.type); Currency currency = Currency.ofCode(payload.currency); Account account = ds.getAccountRepository().insert( diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 0f68af0..94e03af 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -3,7 +3,7 @@ module account.data; import handy_httpd.components.optional; import account.model; -import money.currency; +import util.money; import history.model; interface AccountRepository { diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index c68f5a9..175963b 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -7,9 +7,9 @@ import handy_httpd.components.optional; import account.data; import account.model; -import money.currency; import history.model; import util.sqlite; +import util.money; class SqliteAccountRepository : AccountRepository { private Database db; @@ -18,27 +18,22 @@ class SqliteAccountRepository : AccountRepository { } Optional!Account findById(ulong id) { - return findOne( - db, - "SELECT * FROM account WHERE id = ?", - &parseAccount, - id - ); + return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id); } Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) { - Statement stmt = db.prepare(q"SQL - INSERT INTO account + util.sqlite.update( + db, + "INSERT INTO account (created_at, type, number_suffix, name, currency, description) - VALUES (?, ?, ?, ?, ?, ?) -SQL"); - stmt.bind(1, Clock.currTime(UTC()).toISOExtString()); - stmt.bind(2, type.id); - stmt.bind(3, numberSuffix); - stmt.bind(4, name); - stmt.bind(5, currency.code); - stmt.bind(6, description); - stmt.execute(); + VALUES (?, ?, ?, ?, ?, ?)", + Clock.currTime(UTC()).toISOExtString(), + type.id, + numberSuffix, + name, + currency.code, + description + ); ulong accountId = db.lastInsertRowid(); return findById(accountId).orElseThrow("Couldn't find account!"); } @@ -111,9 +106,7 @@ SQL", ); if (!optionalProps.isNull) return optionalProps.value; // No properties exist, so set them and return the new data. - AccountCreditCardProperties props; - props.account_id = account.id; - props.creditLimit = -1; + const props = AccountCreditCardProperties(account.id, -1); util.sqlite.update( db, "INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)", diff --git a/finnow-api/source/account/model.d b/finnow-api/source/account/model.d index cadcc6b..83add8f 100644 --- a/finnow-api/source/account/model.d +++ b/finnow-api/source/account/model.d @@ -1,18 +1,18 @@ module account.model; import std.datetime; +import std.traits : EnumMembers; -import money.currency; +import util.money; struct AccountType { const string id; const string name; const bool debitsPositive; - import std.traits : EnumMembers; static AccountType fromId(string id) { - static foreach (t; EnumMembers!AccountTypes) { - if (id == t.id) return t; + static foreach (t; ALL_ACCOUNT_TYPES) { + if (t.id == id) return t; } throw new Exception("Invalid account type id " ~ id); } @@ -25,18 +25,20 @@ enum AccountTypes : AccountType { BROKERAGE = AccountType("BROKERAGE", "Brokerage", true) } +immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ]; + struct Account { - ulong id; - SysTime createdAt; - bool archived; - AccountType type; - string numberSuffix; - string name; - Currency currency; - string description; + const ulong id; + const SysTime createdAt; + const bool archived; + const AccountType type; + const string numberSuffix; + const string name; + const Currency currency; + const string description; } struct AccountCreditCardProperties { - ulong account_id; - long creditLimit; + const ulong account_id; + const long creditLimit; } diff --git a/finnow-api/source/account/package.d b/finnow-api/source/account/package.d new file mode 100644 index 0000000..8249de8 --- /dev/null +++ b/finnow-api/source/account/package.d @@ -0,0 +1,6 @@ +module account; + +public import account.api; +public import account.data; +public import account.data_impl_sqlite; +public import account.model; diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 9ab74d7..0aa3531 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -17,6 +17,9 @@ PathHandler mapApiHandlers() { h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus); h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions); + // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! + h.addMapping(Method.POST, API_PATH ~ "/sample-data", &sampleDataEndpoint); + // Auth endpoints: import auth.api; h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin); @@ -26,6 +29,7 @@ PathHandler mapApiHandlers() { // Authenticated endpoints: PathHandler a = new PathHandler(); a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); + a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser); import profile.api; a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles); @@ -58,3 +62,10 @@ private void getStatus(ref HttpRequestContext ctx) { private void getOptions(ref HttpRequestContext ctx) { } + +private void sampleDataEndpoint(ref HttpRequestContext ctx) { + import util.sample_data; + import core.thread; + Thread t = new Thread(() => generateSampleData()); + t.start(); +} diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d index 7c20f4f..ef777d3 100644 --- a/finnow-api/source/auth/api.d +++ b/finnow-api/source/auth/api.d @@ -11,10 +11,22 @@ import auth.service; import auth.data_impl_fs; import util.json; +/// The credentials provided by a user to login. +struct LoginCredentials { + string username; + string password; +} + +/// A token response sent to the user if they've been authenticated. +struct TokenResponse { + const string token; +} + void postLogin(ref HttpRequestContext ctx) { LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx); if (!validateUsername(loginCredentials.username)) { ctx.response.status = HttpStatus.UNAUTHORIZED; + ctx.response.writeBodyString("Username is not valid."); return; } UserRepository userRepo = new FileSystemUserRepository(); @@ -33,6 +45,10 @@ void postLogin(ref HttpRequestContext ctx) { writeJsonBody(ctx, TokenResponse(token)); } +struct UsernameAvailabilityResponse { + const bool available; +} + void getUsernameAvailability(ref HttpRequestContext ctx) { Optional!string username = ctx.request.queryParams.getFirst("username"); if (username.isNull) { @@ -45,6 +61,11 @@ void getUsernameAvailability(ref HttpRequestContext ctx) { writeJsonBody(ctx, UsernameAvailabilityResponse(available)); } +struct RegistrationData { + string username; + string password; +} + void postRegister(ref HttpRequestContext ctx) { RegistrationData registrationData = readJsonPayload!RegistrationData(ctx); if (!validateUsername(registrationData.username)) { @@ -63,13 +84,8 @@ void postRegister(ref HttpRequestContext ctx) { ctx.response.writeBodyString("Username is taken."); return; } - - import botan.passhash.bcrypt : generateBcrypt; - import botan.rng.auto_rng; - - RandomNumberGenerator rng = new AutoSeededRNG(); - string passwordHash = generateBcrypt(registrationData.password, rng, 12); - User user = userRepo.createUser(registrationData.username, passwordHash); + + User user = createNewUser(userRepo, registrationData.username, registrationData.password); infoF!"Created user: %s"(registrationData.username); string token = generateAccessToken(user); writeJsonBody(ctx, TokenResponse(token)); @@ -79,3 +95,10 @@ void getMyUser(ref HttpRequestContext ctx) { AuthContext auth = getAuthContext(ctx); ctx.response.writeBodyString(auth.user.username); } + +void deleteMyUser(ref HttpRequestContext ctx) { + AuthContext auth = getAuthContext(ctx); + UserRepository userRepo = new FileSystemUserRepository(); + deleteUser(auth.user, userRepo); + infoF!"Deleted user: %s"(auth.user.username); +} diff --git a/finnow-api/source/auth/data.d b/finnow-api/source/auth/data.d index acf86a0..4ecc6b7 100644 --- a/finnow-api/source/auth/data.d +++ b/finnow-api/source/auth/data.d @@ -3,28 +3,9 @@ module auth.data; import handy_httpd.components.optional; import auth.model; -/// The credentials provided by a user to login. -struct LoginCredentials { - string username; - string password; -} - -/// A token response sent to the user if they've been authenticated. -struct TokenResponse { - string token; -} - -struct UsernameAvailabilityResponse { - bool available; -} - -struct RegistrationData { - string username; - string password; -} - interface UserRepository { Optional!User findByUsername(string username); + User[] findAll(); User createUser(string username, string passwordHash); void deleteByUsername(string username); } diff --git a/finnow-api/source/auth/data_impl_fs.d b/finnow-api/source/auth/data_impl_fs.d index f63cb53..5064955 100644 --- a/finnow-api/source/auth/data_impl_fs.d +++ b/finnow-api/source/auth/data_impl_fs.d @@ -27,11 +27,16 @@ class FileSystemUserRepository : UserRepository { ) { return Optional!User.empty; } - JSONValue userObj = parseJSON(readText(getUserDataFile(username))); - return Optional!User.of(User( - username, - userObj.object["passwordHash"].str - )); + return Optional!User.of(readUser(username)); + } + + User[] findAll() { + User[] users; + foreach (DirEntry entry; dirEntries(this.usersDir, SpanMode.shallow, false)) { + string username = baseName(entry.name); + users ~= readUser(username); + } + return users; } User createUser(string username, string passwordHash) { @@ -59,4 +64,15 @@ class FileSystemUserRepository : UserRepository { private string getUserDataFile(string username) { return buildPath(this.usersDir, username, "user-data.json"); } + + private User readUser(string username) { + if (!exists(getUserDataFile(username))) { + throw new Exception("User data file for " ~ username ~ " doesn't exist."); + } + JSONValue userObj = parseJSON(readText(getUserDataFile(username))); + return User( + username, + userObj.object["passwordHash"].str + ); + } } \ No newline at end of file diff --git a/finnow-api/source/auth/package.d b/finnow-api/source/auth/package.d new file mode 100644 index 0000000..9e8c30a --- /dev/null +++ b/finnow-api/source/auth/package.d @@ -0,0 +1,7 @@ +module auth; + +public import auth.api; +public import auth.data; +public import auth.data_impl_fs; +public import auth.model; +public import auth.service; \ No newline at end of file diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d index a0a455f..6d1b6b7 100644 --- a/finnow-api/source/auth/service.d +++ b/finnow-api/source/auth/service.d @@ -11,6 +11,18 @@ import auth.data_impl_fs; const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config! +User createNewUser(UserRepository repo, string username, string password) { + import botan.passhash.bcrypt : generateBcrypt; + import botan.rng.auto_rng; + RandomNumberGenerator rng = new AutoSeededRNG(); + string passwordHash = generateBcrypt(password, rng, 12); + return repo.createUser(username, passwordHash); +} + +void deleteUser(User user, UserRepository repo) { + repo.deleteByUsername(user.username); +} + /** * Generates a new JWT access token for a user. * Params: diff --git a/finnow-api/source/history/data.d b/finnow-api/source/history/data.d index 94a4c44..7cff8b4 100644 --- a/finnow-api/source/history/data.d +++ b/finnow-api/source/history/data.d @@ -10,5 +10,6 @@ interface HistoryRepository { Optional!History findById(ulong id); HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit); HistoryItemText getTextItem(ulong itemId); + void addTextItem(ulong historyId, SysTime timestamp, string text); void deleteById(ulong id); } \ No newline at end of file diff --git a/finnow-api/source/history/data_impl_sqlite.d b/finnow-api/source/history/data_impl_sqlite.d index b4f1ffd..95e3f3b 100644 --- a/finnow-api/source/history/data_impl_sqlite.d +++ b/finnow-api/source/history/data_impl_sqlite.d @@ -7,6 +7,7 @@ import d2sqlite3; import history.data; import history.model; +import util.sqlite; class SqliteHistoryRepository : HistoryRepository { private Database db; @@ -47,6 +48,15 @@ SQL"; return parseTextItem(result.front); } + void addTextItem(ulong historyId, SysTime timestamp, string text) { + ulong itemId = addItem(historyId, timestamp, HistoryItemType.TEXT); + update( + db, + "INSERT INTO history_item_text (item_id, content) VALUES (?, ?)", + itemId, text + ); + } + void deleteById(ulong id) { Statement stmt = db.prepare("DELETE FROM history WHERE id = ?"); stmt.bind(1, id); @@ -72,4 +82,13 @@ SQL"; item.content = row.peek!string(1); return item; } + + private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) { + update( + db, + "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)", + historyId, timestamp, type + ); + return db.lastInsertRowid(); + } } \ No newline at end of file diff --git a/finnow-api/source/money/currency.d b/finnow-api/source/money/currency.d deleted file mode 100644 index 57ad708..0000000 --- a/finnow-api/source/money/currency.d +++ /dev/null @@ -1,33 +0,0 @@ -module money.currency; - -struct Currency { - const char[3] code; - const ubyte fractionalDigits; - const ushort numericCode; - - import std.traits : isSomeString, EnumMembers; - static Currency ofCode(S)(S code) if (isSomeString!S) { - if (code.length != 3) { - throw new Exception("Invalid currency code: " ~ code); - } - static foreach (c; EnumMembers!Currencies) { - if (c.code == code) return c; - } - throw new Exception("Unknown currency code: " ~ code); - } -} - -enum Currencies : Currency { - USD = Currency("USD", 2, 840), - CAD = Currency("CAD", 2, 124), - GBP = Currency("GBP", 2, 826), - EUR = Currency("EUR", 2, 978), - CHF = Currency("CHF", 2, 756), - ZAR = Currency("ZAR", 2, 710), - JPY = Currency("JPY", 0, 392), - INR = Currency("INR", 2, 356) -} - -unittest { - assert(Currency.ofCode("USD") == Currencies.USD); -} diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 678be4f..7641ee2 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -3,6 +3,7 @@ module profile.data; import handy_httpd.components.optional; import profile.model; +/// Repository for interacting with the set of profiles belonging to a user. interface ProfileRepository { Optional!Profile findByName(string name); Profile createProfile(string name); diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 7a4250a..aa72714 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -12,6 +12,7 @@ import util.sqlite; const DEFAULT_USERS_DIR = "users"; +/// Profile repository that uses an SQLite3 database file for each profile. class FileSystemProfileRepository : ProfileRepository { import std.path; import std.file; @@ -97,11 +98,6 @@ class SqlitePropertiesRepository : PropertiesRepository { r => r.peek!string(0), propertyName ); - // Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?"); - // stmt.bind(1, propertyName); - // ResultRange result = stmt.execute(); - // if (result.empty) return Optional!string.empty; - // return Optional!string.of(result.front.peek!string(0)); } void setProperty(string name, string value) { diff --git a/finnow-api/source/profile/package.d b/finnow-api/source/profile/package.d index c087ff7..069e51f 100644 --- a/finnow-api/source/profile/package.d +++ b/finnow-api/source/profile/package.d @@ -4,3 +4,9 @@ * completely separate from all others. */ module profile; + +public import profile.api; +public import profile.data; +public import profile.data_impl_sqlite; +public import profile.model; +public import profile.service; diff --git a/finnow-api/source/profile/service.d b/finnow-api/source/profile/service.d index 3fec1ff..6d541de 100644 --- a/finnow-api/source/profile/service.d +++ b/finnow-api/source/profile/service.d @@ -18,7 +18,7 @@ bool validateProfileName(string name) { import std.regex; import std.uni : toLower; if (name is null || name.length < 3) return false; - auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`); + auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_-]+$`); return !matchFirst(name, r).empty; } diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d new file mode 100644 index 0000000..d547082 --- /dev/null +++ b/finnow-api/source/transaction/data.d @@ -0,0 +1,46 @@ +module transaction.data; + +import handy_httpd.components.optional; +import std.datetime; + +import transaction.model; +import util.money; + +interface TransactionVendorRepository { + Optional!TransactionVendor findById(ulong id); + TransactionVendor[] findAll(); + bool existsByName(string name); + TransactionVendor insert(string name, string description); + void deleteById(ulong id); + TransactionVendor updateById(ulong id, string name, string description); +} + +interface TransactionCategoryRepository { + Optional!TransactionCategory findById(ulong id); + TransactionCategory[] findAllByParentId(Optional!ulong parentId); + TransactionCategory insert(Optional!ulong parentId, string name, string description, string color); + void deleteById(ulong id); + TransactionCategory updateById(ulong id, string name, string description, string color); +} + +interface TransactionTagRepository { + Optional!TransactionTag findById(ulong id); + Optional!TransactionTag findByName(string name); + TransactionTag[] findAll(); + TransactionTag insert(string name); + void deleteById(ulong id); +} + +interface TransactionRepository { + Optional!Transaction findById(ulong id); + Transaction insert( + SysTime timestamp, + SysTime addedAt, + ulong amount, + Currency currency, + string description, + Optional!ulong vendorId, + Optional!ulong categoryId + ); + void deleteById(ulong id); +} diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d new file mode 100644 index 0000000..d882450 --- /dev/null +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -0,0 +1,181 @@ +module transaction.data_impl_sqlite; + +import handy_httpd.components.optional; +import std.datetime; +import d2sqlite3; + +import transaction.model; +import transaction.data; +import util.sqlite; +import util.money; + +class SqliteTransactionVendorRepository : TransactionVendorRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!TransactionVendor findById(ulong id) { + return util.sqlite.findById(db, "transaction_vendor", &parseVendor, id); + } + + TransactionVendor[] findAll() { + return util.sqlite.findAll( + db, + "SELECT * FROM transaction_vendor ORDER BY name ASC", + &parseVendor + ); + } + + bool existsByName(string name) { + return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name); + } + + TransactionVendor insert(string name, string description) { + util.sqlite.update( + db, + "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)", + name, description + ); + ulong id = db.lastInsertRowid(); + return findById(id).orElseThrow(); + } + + void deleteById(ulong id) { + util.sqlite.deleteById(db, "transaction_vendor", id); + } + + TransactionVendor updateById(ulong id, string name, string description) { + util.sqlite.update( + db, + "UPDATE transaction_vendor SET name = ?, description = ? WHERE id = ?", + name, description, id + ); + return findById(id).orElseThrow(); + } + + private static TransactionVendor parseVendor(Row row) { + return TransactionVendor( + row.peek!ulong(0), + row.peek!string(1), + row.peek!string(2) + ); + } +} + +class SqliteTransactionCategoryRepository : TransactionCategoryRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!TransactionCategory findById(ulong id) { + return util.sqlite.findById(db, "transaction_category", &parseCategory, id); + } + + TransactionCategory[] findAllByParentId(Optional!ulong parentId) { + if (parentId) { + return util.sqlite.findAll( + db, + "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC", + &parseCategory, + parentId.value + ); + } + return util.sqlite.findAll( + db, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + &parseCategory + ); + } + + TransactionCategory insert(Optional!ulong parentId, string name, string description, string color) { + util.sqlite.update( + db, + "INSERT INTO transaction_category + (parent_id, name, description, color) + VALUES (?, ?, ?, ?)", + parentId.asNullable(), name, description, color + ); + ulong id = db.lastInsertRowid(); + return findById(id).orElseThrow(); + } + + void deleteById(ulong id) { + util.sqlite.deleteById(db, "transaction_category", id); + } + + TransactionCategory updateById(ulong id, string name, string description, string color) { + util.sqlite.update( + db, + "UPDATE transaction_category + SET name = ?, description = ?, color = ? + WHERE id = ?", + name, description, color, id + ); + return findById(id).orElseThrow(); + } + + private static TransactionCategory parseCategory(Row row) { + import std.typecons; + return TransactionCategory( + row.peek!ulong(0), + Optional!ulong.of(row.peek!(Nullable!ulong)(1)), + row.peek!string(2), + row.peek!string(3), + row.peek!string(4) + ); + } +} + +class SqliteTransactionTagRepository : TransactionTagRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!TransactionTag findById(ulong id) { + return findOne(db, "SELECT * FROM transaction_tag WHERE id = ?", &parseTag, id); + } + + Optional!TransactionTag findByName(string name) { + return findOne(db, "SELECT * FROM transaction_tag WHERE name = ?", &parseTag, name); + } + + TransactionTag[] findAll() { + return util.sqlite.findAll( + db, + "SELECT * FROM transaction_tag ORDER BY name ASC", + &parseTag + ); + } + + TransactionTag insert(string name) { + auto existingTag = findByName(name); + if (existingTag) { + return existingTag.value; + } + util.sqlite.update( + db, + "INSERT INTO transaction_tag (name) VALUES (?)", + name + ); + ulong id = db.lastInsertRowid(); + return findById(id).orElseThrow(); + } + + void deleteById(ulong id) { + util.sqlite.update( + db, + "DELETE FROM transaction_tag WHERE id = ?", + id + ); + } + + private static TransactionTag parseTag(Row row) { + return TransactionTag( + row.peek!ulong(0), + row.peek!string(1) + ); + } +} diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index 3e0e65c..fd3c9a2 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -1,34 +1,48 @@ module transaction.model; +import handy_httpd.components.optional; import std.datetime; -import money.currency; +import util.money; struct TransactionVendor { - ulong id; - string name; - string description; + const ulong id; + const string name; + const string description; } struct TransactionCategory { - ulong id; - ulong parentId; - string name; - string color; + const ulong id; + const Optional!ulong parentId; + const string name; + const string description; + const string color; } struct TransactionTag { - ulong id; - string name; + const ulong id; + const string name; } struct Transaction { - ulong id; - SysTime timestamp; - SysTime addedAt; - ulong amount; - Currency currency; - string description; - ulong vendorId; - ulong categoryId; -} \ No newline at end of file + const ulong id; + /// The time at which the transaction happened. + const SysTime timestamp; + /// The time at which the transaction entity was saved. + const SysTime addedAt; + const ulong amount; + const Currency currency; + const string description; + const Optional!ulong vendorId; + const Optional!ulong categoryId; +} + +struct TransactionLineItem { + const ulong id; + const ulong transactionId; + const long valuePerItem; + const ulong quantity; + const uint idx; + const string description; + const Optional!ulong categoryId; +} diff --git a/finnow-api/source/transaction/package.d b/finnow-api/source/transaction/package.d new file mode 100644 index 0000000..56af348 --- /dev/null +++ b/finnow-api/source/transaction/package.d @@ -0,0 +1,4 @@ +module transaction; + +public import transaction.data; +public import transaction.model; diff --git a/finnow-api/source/util/json.d b/finnow-api/source/util/json.d index 5c71c9a..6279d30 100644 --- a/finnow-api/source/util/json.d +++ b/finnow-api/source/util/json.d @@ -8,7 +8,7 @@ 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. + * REQUEST status. The type T should not have `const` members. * Params: * ctx = The request context to read from. * Returns: The data that was read. diff --git a/finnow-api/source/util/money.d b/finnow-api/source/util/money.d new file mode 100644 index 0000000..580fac6 --- /dev/null +++ b/finnow-api/source/util/money.d @@ -0,0 +1,83 @@ +module util.money; + +import std.traits : isSomeString, EnumMembers; + +/** + * Basic information about a monetary currency, as defined by ISO 4217. + * https://en.wikipedia.org/wiki/ISO_4217 + */ +struct Currency { + /// The common 3-character code for the currency, like "USD". + const char[3] code; + /// The number of digits after the decimal place that the currency supports. + const ubyte fractionalDigits; + /// The ISO 4217 numeric code for the currency. + const ushort numericCode; + + static Currency ofCode(S)(S code) if (isSomeString!S) { + if (code.length != 3) { + throw new Exception("Invalid currency code: " ~ code); + } + static foreach (c; ALL_CURRENCIES) { + if (c.code == code) return c; + } + throw new Exception("Unknown currency code: " ~ code); + } +} + +/// An enumeration of all available currencies. +enum Currencies : Currency { + AUD = Currency("AUD", 2, 36), + USD = Currency("USD", 2, 840), + CAD = Currency("CAD", 2, 124), + GBP = Currency("GBP", 2, 826), + EUR = Currency("EUR", 2, 978), + CHF = Currency("CHF", 2, 756), + ZAR = Currency("ZAR", 2, 710), + JPY = Currency("JPY", 0, 392), + INR = Currency("INR", 2, 356) +} + +immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies]; + +unittest { + assert(Currency.ofCode("USD") == Currencies.USD); +} + +/** + * A monetary value consisting of an integer value, and a currency. The value + * is interpreted as a multiple of the smallest denomination of the currency, + * so for example, with USD currency, a value of 123 indicates $1.23. + */ +struct MoneyValue { + const Currency currency; + const long value; + + int opCmp(in MoneyValue other) const { + if (other.currency != this.currency) return 0; + if (this.value < other.value) return -1; + if (this.value > other.value) return 1; + return 0; + } + + MoneyValue opBinary(string op)(in MoneyValue rhs) const { + if (rhs.currency != this.currency) + throw new Exception("Cannot perform binary operations on MoneyValues with different currencies."); + static if (op == "+") return MoneyValue(currency, this.value + rhs.value); + static if (op == "-") return MoneyValue(currency, this.value - rhs.value); + static assert(false, "Operator " ~ op ~ " is not supported."); + } + + MoneyValue opBinary(string op)(int rhs) const { + static if (op == "+") return MoneyValue(currency, this.value + rhs); + static if (op == "-") return MoneyValue(currency, this.value - rhs); + static if (op == "*") return MoneyValue(currency, this.value * rhs); + static if (op == "/") return MoneyValue(currency, this.value / rhs); + static assert(false, "Operator " ~ op ~ " is not supported."); + } + + MoneyValue opUnary(string op)() const { + static if (op == "-") return MoneyValue(currency, -this.value); + static assert(false, "Operator " ~ op ~ " is not supported."); + } +} \ No newline at end of file diff --git a/finnow-api/source/util/repository.d b/finnow-api/source/util/repository.d new file mode 100644 index 0000000..3ba19a5 --- /dev/null +++ b/finnow-api/source/util/repository.d @@ -0,0 +1,32 @@ +module util.repository; + +import handy_httpd.components.optional; +import d2sqlite3; +import util.sqlite; + +class CrudRepository(T, string table) + if (__traits(hasMember, T, "id")) { + + protected Database db; + this(Database db) { + this.db = db; + } + + abstract T parseResult(Row r); + + Optional!T findById(ulong id) { + return util.sqlite.findById(db, table, &parseResult, id); + } + + bool existsById(ulong id) { + return util.sqlite.exists( + db, + "SELECT id FROM " ~ table ~ " WHERE id = ?", + id + ); + } + + void deleteById(ulong id) { + util.sqlite.deleteById(db, table, id); + } +} \ No newline at end of file diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d new file mode 100644 index 0000000..4a07b34 --- /dev/null +++ b/finnow-api/source/util/sample_data.d @@ -0,0 +1,69 @@ +module util.sample_data; + +import slf4d; + +import auth; +import profile; +import account; +import util.money; + +import std.random; +import std.conv; +import std.array; + +void generateSampleData() { + UserRepository userRepo = new FileSystemUserRepository; + // Remove all existing user data. + foreach (User user; userRepo.findAll()) { + userRepo.deleteByUsername(user.username); + } + + const int userCount = uniform(5, 10); + for (int i = 0; i < userCount; i++) { + generateRandomUser(i, userRepo); + } + info("Random sample data generation complete."); +} + +void generateRandomUser(int idx, UserRepository userRepo) { + string username = "testuser" ~ idx.to!string; + string password = "testpass"; + infoF!"Generating random user %s, password: %s."(username, password); + User user = createNewUser(userRepo, username, password); + ProfileRepository profileRepo = new FileSystemProfileRepository(username); + const int profileCount = uniform(1, 5); + for (int i = 0; i < profileCount; i++) { + generateRandomProfile(i, profileRepo); + } +} + +void generateRandomProfile(int idx, ProfileRepository profileRepo) { + string profileName = "test-profile-" ~ idx.to!string; + infoF!" Generating random profile %s."(profileName); + Profile profile = profileRepo.createProfile(profileName); + ProfileDataSource ds = profileRepo.getDataSource(profile); + ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); + + const int accountCount = uniform(1, 10); + for (int i = 0; i < accountCount; i++) { + generateRandomAccount(i, ds); + } +} + +void generateRandomAccount(int idx, ProfileDataSource ds) { + AccountRepository accountRepo = ds.getAccountRepository(); + string idxStr = idx.to!string; + string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr; + string name = "Test Account " ~ idxStr; + AccountType type = choice(ALL_ACCOUNT_TYPES); + Currency currency = choice(ALL_CURRENCIES); + string description = "This is a testing account generated by util.sample_data.generateRandomAccount()."; + infoF!" Generating random account: %s, ...%s"(name, numberSuffix); + Account account = accountRepo.insert( + type, + numberSuffix, + name, + currency, + description + ); +} diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d index 4e5b164..e644830 100644 --- a/finnow-api/source/util/sqlite.d +++ b/finnow-api/source/util/sqlite.d @@ -21,6 +21,23 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result return Optional!T.of(resultMapper(result.front)); } +/** + * Tries to find a single entity by its id, selecting all properties. + * Params: + * db = The database to use. + * table = The table to select from. + * resultMapper = A function to map rows to the desired result type. + * id = The entity's id. + * Returns: An optional result. + */ +Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) { + Statement stmt = db.prepare("SELECT * FROM " ~ table); + stmt.bind(1, id); + ResultRange result = stmt.execute(); + if (result.empty) return Optional!T.empty; + return Optional!T.of(resultMapper(result.front)); +} + /** * Finds a list of records from a database. * Params: @@ -67,6 +84,19 @@ int update(Args...)(Database db, string query, Args args) { return db.changes(); } +/** + * Deletes an entity from a table. + * Params: + * db = The database to use. + * table = The table to delete from. + * id = The id of the entity to delete. + */ +void deleteById(Database db, string table, ulong id) { + Statement stmt = db.prepare("DELETE FROM " ~ table ~ " WHERE id = ?"); + stmt.bind(1, id); + stmt.execute(); +} + /** * Wraps a given delegate block of code in an SQL transaction, so that all * operations will be committed at once when done. If an exception is thrown, diff --git a/flutter_app/lib/api/auth.dart b/flutter_app/lib/api/auth.dart index 540073c..3d135d5 100644 --- a/flutter_app/lib/api/auth.dart +++ b/flutter_app/lib/api/auth.dart @@ -76,3 +76,15 @@ Future postRegister(LoginCredentials credentials) async { throw Exception('Registration failed.'); } } + +Future deleteUser(String token) async { + final http.Response response = await http.delete( + Uri.parse('http://localhost:8080/api/me'), + headers: { + 'Authorization': 'Bearer $token' + } + ); + if (response.statusCode != 200) { + throw Exception('Deleting user failed.'); + } +} diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart index 14dfa33..a213c6a 100644 --- a/flutter_app/lib/app.dart +++ b/flutter_app/lib/app.dart @@ -19,7 +19,7 @@ class FinnowApp extends StatelessWidget with WatchItMixin { if (newValue.state.authenticated()) { router.replace('/profiles'); } else { - while (router.canPop()) { + while (router.canPop()) {// Clear the navigation stack. router.pop(); } router.pushReplacement('/login'); diff --git a/flutter_app/lib/components/profile_list_item.dart b/flutter_app/lib/components/profile_list_item.dart new file mode 100644 index 0000000..1fb1815 --- /dev/null +++ b/flutter_app/lib/components/profile_list_item.dart @@ -0,0 +1,32 @@ +import 'package:finnow_app/api/profile.dart'; +import 'package:finnow_app/main.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// A list item that shows a profile in the user's list of all profiles. +class ProfileListItem extends StatelessWidget { + final Profile profile; + const ProfileListItem(this.profile, {super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + getIt().go('/profiles/${profile.name}'); + }, + child: Container( + constraints: const BoxConstraints(maxWidth: 300), + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(top: 10, bottom: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), color: const Color.fromARGB(255, 168, 233, 170)), + child: Row(children: [ + Expanded(child: Text(profile.name)), + TextButton( + onPressed: () { + print('Removing profile: ${profile.name}'); + }, + child: const Text('Remove')) + ]))); + } +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 1d19d6e..79854c4 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -3,12 +3,10 @@ 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'; +import 'router.dart'; +/// The global GetIt instance, used to get registered singletons. final getIt = GetIt.instance; void main() { @@ -16,49 +14,15 @@ void main() { runApp(const FinnowApp()); } +/// Initializes some resources before we start the app. void setup() { + // Register the FinnowApi so it can be called easily from anywhere. getIt.registerSingleton(FinnowApi()); + + // Register the AuthenticationModel singleton to hold the app's current + // authentication information. getIt.registerSingleton(AuthenticationModel()); + + // Register the GoRouter singleton to provide navigation in the app. getIt.registerSingleton(getRouterConfig()); } - -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')) - ], - ), - ), - 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/login_page.dart b/flutter_app/lib/pages/login_page.dart similarity index 98% rename from flutter_app/lib/login_page.dart rename to flutter_app/lib/pages/login_page.dart index 330d1ce..b2a6f5c 100644 --- a/flutter_app/lib/login_page.dart +++ b/flutter_app/lib/pages/login_page.dart @@ -4,8 +4,8 @@ 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'; +import '../api/auth.dart'; +import '../main.dart'; class LoginPage extends StatelessWidget { const LoginPage({super.key}); diff --git a/flutter_app/lib/pages/profile_page.dart b/flutter_app/lib/pages/profile_page.dart new file mode 100644 index 0000000..21c706d --- /dev/null +++ b/flutter_app/lib/pages/profile_page.dart @@ -0,0 +1,17 @@ +import 'package:finnow_app/api/profile.dart'; +import 'package:flutter/material.dart'; + +class ProfilePage extends StatelessWidget { + final Profile profile; + const ProfilePage(this.profile, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + padding: const EdgeInsets.all(10), + child: Text(profile.name) + ) + ); + } +} \ No newline at end of file diff --git a/flutter_app/lib/profiles_page.dart b/flutter_app/lib/pages/profiles_page.dart similarity index 80% rename from flutter_app/lib/profiles_page.dart rename to flutter_app/lib/pages/profiles_page.dart index 47b94b9..3a17949 100644 --- a/flutter_app/lib/profiles_page.dart +++ b/flutter_app/lib/pages/profiles_page.dart @@ -1,8 +1,9 @@ import 'package:finnow_app/auth/model.dart'; +import 'package:finnow_app/components/profile_list_item.dart'; import 'package:flutter/material.dart'; -import 'api/profile.dart'; -import 'main.dart'; +import '../api/profile.dart'; +import '../main.dart'; class ProfilesPage extends StatelessWidget { const ProfilesPage({super.key}); @@ -11,9 +12,9 @@ class ProfilesPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(10), child: Column(children: [ - Text('Profiles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)), + Text('Select a Profile', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)), SizedBox(height: 10), Expanded(child: _ProfilesListView()), ])), @@ -39,7 +40,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> { Widget build(BuildContext context) { return ListView( scrollDirection: Axis.vertical, - children: profiles.map((p) => Text('Profile: ${p.name}')).toList(), + children: profiles.map(ProfileListItem.new).toList(), ); } diff --git a/flutter_app/lib/register_page.dart b/flutter_app/lib/pages/register_page.dart similarity index 100% rename from flutter_app/lib/register_page.dart rename to flutter_app/lib/pages/register_page.dart diff --git a/flutter_app/lib/pages/user_account_page.dart b/flutter_app/lib/pages/user_account_page.dart new file mode 100644 index 0000000..0780b60 --- /dev/null +++ b/flutter_app/lib/pages/user_account_page.dart @@ -0,0 +1,59 @@ +import 'package:finnow_app/api/auth.dart'; +import 'package:finnow_app/auth/model.dart'; +import 'package:finnow_app/components/form_label.dart'; +import 'package:flutter/material.dart'; + +import '../main.dart'; + +class UserAccountPage extends StatelessWidget { + final Authenticated auth; + const UserAccountPage(this.auth, {super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + child: ListView(children: [ + const Text('My User', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)), + const SizedBox(height: 10), + const FormLabel('Username'), + Text(auth.username), + const SizedBox(height: 10), + Row(children: [ + TextButton( + onPressed: () => attemptDeleteUser(context), + child: const Text('Delete my user')) + ]) + ])); + } + + void attemptDeleteUser(BuildContext ctx) async { + bool confirmed = false; + await showDialog( + context: ctx, + builder: (context) { + return AlertDialog( + title: const Text('Confirm User Deletion'), + content: const Text( + 'Are you sure you want to delete your user? This is a permanent action that cannot be undone.'), + actions: [ + FilledButton( + onPressed: () { + confirmed = true; + Navigator.of(context).pop(); + }, + child: Text('Ok')), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('Cancel')) + ]); + }); + if (confirmed) { + await deleteUser(auth.token); + getIt().state = Unauthenticated(); + } + } +} diff --git a/flutter_app/lib/router.dart b/flutter_app/lib/router.dart new file mode 100644 index 0000000..9d1e3a6 --- /dev/null +++ b/flutter_app/lib/router.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'api/profile.dart'; +import 'auth/model.dart'; +import 'pages/login_page.dart'; +import 'main.dart'; +import 'pages/profile_page.dart'; +import 'pages/profiles_page.dart'; +import 'pages/register_page.dart'; +import 'pages/user_account_page.dart'; + +GoRouter getRouterConfig() { + return GoRouter(routes: [ + GoRoute(path: '/login', builder: (ctx, state) => const LoginPage()), + GoRoute(path: '/register', builder: (ctx, state) => const RegisterPage()), + // Once a user has logged in, they're directed to a scaffold for the /profiles page. + ShellRoute( + builder: getAppScaffold, + redirect: (context, state) { + // If the user isn't authenticated when they navigate here, send them + // back to /login. + final bool authenticated = + getIt().state.authenticated(); + return authenticated ? null : '/login'; + }, + routes: [ + // The "/" route is just a dummy landing route that redirects to + // /profiles if the user is authenticated, or /login otherwise. + GoRoute( + path: '/', + redirect: (context, state) { + final bool authenticated = + getIt().state.authenticated(); + return authenticated ? '/profiles' : '/login'; + }), + GoRoute( + path: '/profiles', + builder: (context, state) => const ProfilesPage(), + routes: [ + GoRoute( + path: ':profile', + builder: (context, state) { + final String profileName = + state.pathParameters['profile']!; + return ProfilePage(Profile(profileName)); + }), + ]), + GoRoute( + path: '/user-account', + builder: (ctx, state) => UserAccountPage(getIt().state as Authenticated) + ) + ]), + ]); +} + +/// Gets the scaffold for the main authenticated view of the app, which is +/// where the user is viewing their set of profiles. +Widget getAppScaffold(BuildContext context, GoRouterState state, Widget child) { + return Scaffold( + body: child, + appBar: AppBar( + title: const Text('Finnow'), + backgroundColor: Colors.grey, + actions: [ + IconButton( + onPressed: () { + final router = getIt(); + router.push('/user-account'); + }, + icon: const Icon(Icons.account_circle) + ), + IconButton( + onPressed: () => + getIt().state = Unauthenticated(), + icon: const Icon(Icons.logout)) + ], + ), + bottomNavigationBar: + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + BackButton( + onPressed: () { + GoRouter router = getIt(); + if (router.canPop()) router.pop(); + }, + ), + IconButton( + onPressed: () { + GoRouter router = getIt(); + router.replace('/profiles'); + }, + icon: const Icon(Icons.home)) + ]), + ); +} diff --git a/flutter_app/test/widget_test.dart b/flutter_app/test/widget_test.dart index 63d80bc..f00ca05 100644 --- a/flutter_app/test/widget_test.dart +++ b/flutter_app/test/widget_test.dart @@ -8,7 +8,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:finnow_app/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async {