Added bruno API thing, user account page, and user deletion logic.
This commit is contained in:
parent
270dcc6020
commit
f5c75ccf35
|
@ -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:
|
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.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.
|
* `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.
|
* `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.
|
* `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.
|
||||||
|
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
meta {
|
||||||
|
name: Delete My User
|
||||||
|
type: http
|
||||||
|
seq: 4
|
||||||
|
}
|
||||||
|
|
||||||
|
delete {
|
||||||
|
url: {{base_url}}/me
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
|
@ -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}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
meta {
|
||||||
|
name: My User
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{base_url}}/me
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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.
|
||||||
|
|
||||||
|
}
|
|
@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -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}}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"name": "Finnow",
|
||||||
|
"type": "collection",
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git"
|
||||||
|
]
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
vars {
|
||||||
|
username: testuser0
|
||||||
|
password: testpass
|
||||||
|
profile: test-profile-0
|
||||||
|
base_url: http://localhost:8080/api
|
||||||
|
}
|
||||||
|
vars:secret [
|
||||||
|
access_token
|
||||||
|
]
|
|
@ -5,6 +5,7 @@
|
||||||
"botan": "1.13.6",
|
"botan": "1.13.6",
|
||||||
"botan-math": "1.0.4",
|
"botan-math": "1.0.4",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
|
"handy-httpd": {"path":"../../../github-andrewlalis/handy-httpd"},
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"jwt": "0.4.0",
|
"jwt": "0.4.0",
|
||||||
"memutils": "1.0.10",
|
"memutils": "1.0.10",
|
||||||
|
|
|
@ -49,6 +49,7 @@ CREATE TABLE transaction_category (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
parent_id INTEGER,
|
parent_id INTEGER,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
color TEXT NOT NULL DEFAULT 'FFFFFF',
|
color TEXT NOT NULL DEFAULT 'FFFFFF',
|
||||||
CONSTRAINT fk_transaction_category_parent
|
CONSTRAINT fk_transaction_category_parent
|
||||||
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
|
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
|
||||||
|
|
|
@ -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;
|
module account.api;
|
||||||
|
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
|
|
||||||
import profile.service;
|
import profile.service;
|
||||||
import account.model;
|
import account.model;
|
||||||
import money.currency;
|
import util.money;
|
||||||
import util.json;
|
import util.json;
|
||||||
|
|
||||||
|
/// The data the API provides for an Account entity.
|
||||||
struct AccountResponse {
|
struct AccountResponse {
|
||||||
ulong id;
|
ulong id;
|
||||||
string createdAt;
|
string createdAt;
|
||||||
|
@ -33,9 +38,10 @@ struct AccountResponse {
|
||||||
|
|
||||||
void handleGetAccounts(ref HttpRequestContext ctx) {
|
void handleGetAccounts(ref HttpRequestContext ctx) {
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
|
import std.array;
|
||||||
auto ds = getProfileDataSource(ctx);
|
auto ds = getProfileDataSource(ctx);
|
||||||
auto accounts = ds.getAccountRepository().findAll()
|
auto accounts = ds.getAccountRepository().findAll()
|
||||||
.map!(a => AccountResponse.of(a));
|
.map!(a => AccountResponse.of(a)).array;
|
||||||
writeJsonBody(ctx, accounts);
|
writeJsonBody(ctx, accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +53,7 @@ void handleGetAccount(ref HttpRequestContext ctx) {
|
||||||
writeJsonBody(ctx, AccountResponse.of(account));
|
writeJsonBody(ctx, AccountResponse.of(account));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The data provided by a user to create a new account.
|
||||||
struct AccountCreationPayload {
|
struct AccountCreationPayload {
|
||||||
string type;
|
string type;
|
||||||
string numberSuffix;
|
string numberSuffix;
|
||||||
|
@ -58,6 +65,7 @@ struct AccountCreationPayload {
|
||||||
void handleCreateAccount(ref HttpRequestContext ctx) {
|
void handleCreateAccount(ref HttpRequestContext ctx) {
|
||||||
auto ds = getProfileDataSource(ctx);
|
auto ds = getProfileDataSource(ctx);
|
||||||
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
|
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
|
||||||
|
// TODO: Validate the account creation payload.
|
||||||
AccountType type = AccountType.fromId(payload.type);
|
AccountType type = AccountType.fromId(payload.type);
|
||||||
Currency currency = Currency.ofCode(payload.currency);
|
Currency currency = Currency.ofCode(payload.currency);
|
||||||
Account account = ds.getAccountRepository().insert(
|
Account account = ds.getAccountRepository().insert(
|
||||||
|
|
|
@ -3,7 +3,7 @@ module account.data;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
|
|
||||||
import account.model;
|
import account.model;
|
||||||
import money.currency;
|
import util.money;
|
||||||
import history.model;
|
import history.model;
|
||||||
|
|
||||||
interface AccountRepository {
|
interface AccountRepository {
|
||||||
|
|
|
@ -7,9 +7,9 @@ import handy_httpd.components.optional;
|
||||||
|
|
||||||
import account.data;
|
import account.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
import money.currency;
|
|
||||||
import history.model;
|
import history.model;
|
||||||
import util.sqlite;
|
import util.sqlite;
|
||||||
|
import util.money;
|
||||||
|
|
||||||
class SqliteAccountRepository : AccountRepository {
|
class SqliteAccountRepository : AccountRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
@ -18,27 +18,22 @@ class SqliteAccountRepository : AccountRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional!Account findById(ulong id) {
|
Optional!Account findById(ulong id) {
|
||||||
return findOne(
|
return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id);
|
||||||
db,
|
|
||||||
"SELECT * FROM account WHERE id = ?",
|
|
||||||
&parseAccount,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
||||||
Statement stmt = db.prepare(q"SQL
|
util.sqlite.update(
|
||||||
INSERT INTO account
|
db,
|
||||||
|
"INSERT INTO account
|
||||||
(created_at, type, number_suffix, name, currency, description)
|
(created_at, type, number_suffix, name, currency, description)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
SQL");
|
Clock.currTime(UTC()).toISOExtString(),
|
||||||
stmt.bind(1, Clock.currTime(UTC()).toISOExtString());
|
type.id,
|
||||||
stmt.bind(2, type.id);
|
numberSuffix,
|
||||||
stmt.bind(3, numberSuffix);
|
name,
|
||||||
stmt.bind(4, name);
|
currency.code,
|
||||||
stmt.bind(5, currency.code);
|
description
|
||||||
stmt.bind(6, description);
|
);
|
||||||
stmt.execute();
|
|
||||||
ulong accountId = db.lastInsertRowid();
|
ulong accountId = db.lastInsertRowid();
|
||||||
return findById(accountId).orElseThrow("Couldn't find account!");
|
return findById(accountId).orElseThrow("Couldn't find account!");
|
||||||
}
|
}
|
||||||
|
@ -111,9 +106,7 @@ SQL",
|
||||||
);
|
);
|
||||||
if (!optionalProps.isNull) return optionalProps.value;
|
if (!optionalProps.isNull) return optionalProps.value;
|
||||||
// No properties exist, so set them and return the new data.
|
// No properties exist, so set them and return the new data.
|
||||||
AccountCreditCardProperties props;
|
const props = AccountCreditCardProperties(account.id, -1);
|
||||||
props.account_id = account.id;
|
|
||||||
props.creditLimit = -1;
|
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
|
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
module account.model;
|
module account.model;
|
||||||
|
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
import std.traits : EnumMembers;
|
||||||
|
|
||||||
import money.currency;
|
import util.money;
|
||||||
|
|
||||||
struct AccountType {
|
struct AccountType {
|
||||||
const string id;
|
const string id;
|
||||||
const string name;
|
const string name;
|
||||||
const bool debitsPositive;
|
const bool debitsPositive;
|
||||||
|
|
||||||
import std.traits : EnumMembers;
|
|
||||||
static AccountType fromId(string id) {
|
static AccountType fromId(string id) {
|
||||||
static foreach (t; EnumMembers!AccountTypes) {
|
static foreach (t; ALL_ACCOUNT_TYPES) {
|
||||||
if (id == t.id) return t;
|
if (t.id == id) return t;
|
||||||
}
|
}
|
||||||
throw new Exception("Invalid account type id " ~ id);
|
throw new Exception("Invalid account type id " ~ id);
|
||||||
}
|
}
|
||||||
|
@ -25,18 +25,20 @@ enum AccountTypes : AccountType {
|
||||||
BROKERAGE = AccountType("BROKERAGE", "Brokerage", true)
|
BROKERAGE = AccountType("BROKERAGE", "Brokerage", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
|
||||||
|
|
||||||
struct Account {
|
struct Account {
|
||||||
ulong id;
|
const ulong id;
|
||||||
SysTime createdAt;
|
const SysTime createdAt;
|
||||||
bool archived;
|
const bool archived;
|
||||||
AccountType type;
|
const AccountType type;
|
||||||
string numberSuffix;
|
const string numberSuffix;
|
||||||
string name;
|
const string name;
|
||||||
Currency currency;
|
const Currency currency;
|
||||||
string description;
|
const string description;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AccountCreditCardProperties {
|
struct AccountCreditCardProperties {
|
||||||
ulong account_id;
|
const ulong account_id;
|
||||||
long creditLimit;
|
const long creditLimit;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
module account;
|
||||||
|
|
||||||
|
public import account.api;
|
||||||
|
public import account.data;
|
||||||
|
public import account.data_impl_sqlite;
|
||||||
|
public import account.model;
|
|
@ -17,6 +17,9 @@ PathHandler mapApiHandlers() {
|
||||||
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
|
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
|
||||||
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
|
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:
|
// Auth endpoints:
|
||||||
import auth.api;
|
import auth.api;
|
||||||
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
|
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
|
||||||
|
@ -26,6 +29,7 @@ PathHandler mapApiHandlers() {
|
||||||
// Authenticated endpoints:
|
// Authenticated endpoints:
|
||||||
PathHandler a = new PathHandler();
|
PathHandler a = new PathHandler();
|
||||||
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
||||||
|
a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
|
||||||
|
|
||||||
import profile.api;
|
import profile.api;
|
||||||
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
|
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 getOptions(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
|
||||||
|
import util.sample_data;
|
||||||
|
import core.thread;
|
||||||
|
Thread t = new Thread(() => generateSampleData());
|
||||||
|
t.start();
|
||||||
|
}
|
||||||
|
|
|
@ -11,10 +11,22 @@ import auth.service;
|
||||||
import auth.data_impl_fs;
|
import auth.data_impl_fs;
|
||||||
import util.json;
|
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) {
|
void postLogin(ref HttpRequestContext ctx) {
|
||||||
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
|
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
|
||||||
if (!validateUsername(loginCredentials.username)) {
|
if (!validateUsername(loginCredentials.username)) {
|
||||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||||
|
ctx.response.writeBodyString("Username is not valid.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
UserRepository userRepo = new FileSystemUserRepository();
|
UserRepository userRepo = new FileSystemUserRepository();
|
||||||
|
@ -33,6 +45,10 @@ void postLogin(ref HttpRequestContext ctx) {
|
||||||
writeJsonBody(ctx, TokenResponse(token));
|
writeJsonBody(ctx, TokenResponse(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct UsernameAvailabilityResponse {
|
||||||
|
const bool available;
|
||||||
|
}
|
||||||
|
|
||||||
void getUsernameAvailability(ref HttpRequestContext ctx) {
|
void getUsernameAvailability(ref HttpRequestContext ctx) {
|
||||||
Optional!string username = ctx.request.queryParams.getFirst("username");
|
Optional!string username = ctx.request.queryParams.getFirst("username");
|
||||||
if (username.isNull) {
|
if (username.isNull) {
|
||||||
|
@ -45,6 +61,11 @@ void getUsernameAvailability(ref HttpRequestContext ctx) {
|
||||||
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
|
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RegistrationData {
|
||||||
|
string username;
|
||||||
|
string password;
|
||||||
|
}
|
||||||
|
|
||||||
void postRegister(ref HttpRequestContext ctx) {
|
void postRegister(ref HttpRequestContext ctx) {
|
||||||
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
|
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
|
||||||
if (!validateUsername(registrationData.username)) {
|
if (!validateUsername(registrationData.username)) {
|
||||||
|
@ -63,13 +84,8 @@ void postRegister(ref HttpRequestContext ctx) {
|
||||||
ctx.response.writeBodyString("Username is taken.");
|
ctx.response.writeBodyString("Username is taken.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
import botan.passhash.bcrypt : generateBcrypt;
|
User user = createNewUser(userRepo, registrationData.username, registrationData.password);
|
||||||
import botan.rng.auto_rng;
|
|
||||||
|
|
||||||
RandomNumberGenerator rng = new AutoSeededRNG();
|
|
||||||
string passwordHash = generateBcrypt(registrationData.password, rng, 12);
|
|
||||||
User user = userRepo.createUser(registrationData.username, passwordHash);
|
|
||||||
infoF!"Created user: %s"(registrationData.username);
|
infoF!"Created user: %s"(registrationData.username);
|
||||||
string token = generateAccessToken(user);
|
string token = generateAccessToken(user);
|
||||||
writeJsonBody(ctx, TokenResponse(token));
|
writeJsonBody(ctx, TokenResponse(token));
|
||||||
|
@ -79,3 +95,10 @@ void getMyUser(ref HttpRequestContext ctx) {
|
||||||
AuthContext auth = getAuthContext(ctx);
|
AuthContext auth = getAuthContext(ctx);
|
||||||
ctx.response.writeBodyString(auth.user.username);
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -3,28 +3,9 @@ module auth.data;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
import auth.model;
|
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 {
|
interface UserRepository {
|
||||||
Optional!User findByUsername(string username);
|
Optional!User findByUsername(string username);
|
||||||
|
User[] findAll();
|
||||||
User createUser(string username, string passwordHash);
|
User createUser(string username, string passwordHash);
|
||||||
void deleteByUsername(string username);
|
void deleteByUsername(string username);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,11 +27,16 @@ class FileSystemUserRepository : UserRepository {
|
||||||
) {
|
) {
|
||||||
return Optional!User.empty;
|
return Optional!User.empty;
|
||||||
}
|
}
|
||||||
JSONValue userObj = parseJSON(readText(getUserDataFile(username)));
|
return Optional!User.of(readUser(username));
|
||||||
return Optional!User.of(User(
|
}
|
||||||
username,
|
|
||||||
userObj.object["passwordHash"].str
|
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) {
|
User createUser(string username, string passwordHash) {
|
||||||
|
@ -59,4 +64,15 @@ class FileSystemUserRepository : UserRepository {
|
||||||
private string getUserDataFile(string username) {
|
private string getUserDataFile(string username) {
|
||||||
return buildPath(this.usersDir, username, "user-data.json");
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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;
|
|
@ -11,6 +11,18 @@ import auth.data_impl_fs;
|
||||||
|
|
||||||
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
|
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.
|
* Generates a new JWT access token for a user.
|
||||||
* Params:
|
* Params:
|
||||||
|
|
|
@ -10,5 +10,6 @@ interface HistoryRepository {
|
||||||
Optional!History findById(ulong id);
|
Optional!History findById(ulong id);
|
||||||
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit);
|
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit);
|
||||||
HistoryItemText getTextItem(ulong itemId);
|
HistoryItemText getTextItem(ulong itemId);
|
||||||
|
void addTextItem(ulong historyId, SysTime timestamp, string text);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
}
|
}
|
|
@ -7,6 +7,7 @@ import d2sqlite3;
|
||||||
|
|
||||||
import history.data;
|
import history.data;
|
||||||
import history.model;
|
import history.model;
|
||||||
|
import util.sqlite;
|
||||||
|
|
||||||
class SqliteHistoryRepository : HistoryRepository {
|
class SqliteHistoryRepository : HistoryRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
@ -47,6 +48,15 @@ SQL";
|
||||||
return parseTextItem(result.front);
|
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) {
|
void deleteById(ulong id) {
|
||||||
Statement stmt = db.prepare("DELETE FROM history WHERE id = ?");
|
Statement stmt = db.prepare("DELETE FROM history WHERE id = ?");
|
||||||
stmt.bind(1, id);
|
stmt.bind(1, id);
|
||||||
|
@ -72,4 +82,13 @@ SQL";
|
||||||
item.content = row.peek!string(1);
|
item.content = row.peek!string(1);
|
||||||
return item;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -3,6 +3,7 @@ module profile.data;
|
||||||
import handy_httpd.components.optional;
|
import handy_httpd.components.optional;
|
||||||
import profile.model;
|
import profile.model;
|
||||||
|
|
||||||
|
/// Repository for interacting with the set of profiles belonging to a user.
|
||||||
interface ProfileRepository {
|
interface ProfileRepository {
|
||||||
Optional!Profile findByName(string name);
|
Optional!Profile findByName(string name);
|
||||||
Profile createProfile(string name);
|
Profile createProfile(string name);
|
||||||
|
|
|
@ -12,6 +12,7 @@ import util.sqlite;
|
||||||
|
|
||||||
const DEFAULT_USERS_DIR = "users";
|
const DEFAULT_USERS_DIR = "users";
|
||||||
|
|
||||||
|
/// Profile repository that uses an SQLite3 database file for each profile.
|
||||||
class FileSystemProfileRepository : ProfileRepository {
|
class FileSystemProfileRepository : ProfileRepository {
|
||||||
import std.path;
|
import std.path;
|
||||||
import std.file;
|
import std.file;
|
||||||
|
@ -97,11 +98,6 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
||||||
r => r.peek!string(0),
|
r => r.peek!string(0),
|
||||||
propertyName
|
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) {
|
void setProperty(string name, string value) {
|
||||||
|
|
|
@ -4,3 +4,9 @@
|
||||||
* completely separate from all others.
|
* completely separate from all others.
|
||||||
*/
|
*/
|
||||||
module profile;
|
module profile;
|
||||||
|
|
||||||
|
public import profile.api;
|
||||||
|
public import profile.data;
|
||||||
|
public import profile.data_impl_sqlite;
|
||||||
|
public import profile.model;
|
||||||
|
public import profile.service;
|
||||||
|
|
|
@ -18,7 +18,7 @@ bool validateProfileName(string name) {
|
||||||
import std.regex;
|
import std.regex;
|
||||||
import std.uni : toLower;
|
import std.uni : toLower;
|
||||||
if (name is null || name.length < 3) return false;
|
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;
|
return !matchFirst(name, r).empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,34 +1,48 @@
|
||||||
module transaction.model;
|
module transaction.model;
|
||||||
|
|
||||||
|
import handy_httpd.components.optional;
|
||||||
import std.datetime;
|
import std.datetime;
|
||||||
|
|
||||||
import money.currency;
|
import util.money;
|
||||||
|
|
||||||
struct TransactionVendor {
|
struct TransactionVendor {
|
||||||
ulong id;
|
const ulong id;
|
||||||
string name;
|
const string name;
|
||||||
string description;
|
const string description;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionCategory {
|
struct TransactionCategory {
|
||||||
ulong id;
|
const ulong id;
|
||||||
ulong parentId;
|
const Optional!ulong parentId;
|
||||||
string name;
|
const string name;
|
||||||
string color;
|
const string description;
|
||||||
|
const string color;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransactionTag {
|
struct TransactionTag {
|
||||||
ulong id;
|
const ulong id;
|
||||||
string name;
|
const string name;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Transaction {
|
struct Transaction {
|
||||||
ulong id;
|
const ulong id;
|
||||||
SysTime timestamp;
|
/// The time at which the transaction happened.
|
||||||
SysTime addedAt;
|
const SysTime timestamp;
|
||||||
ulong amount;
|
/// The time at which the transaction entity was saved.
|
||||||
Currency currency;
|
const SysTime addedAt;
|
||||||
string description;
|
const ulong amount;
|
||||||
ulong vendorId;
|
const Currency currency;
|
||||||
ulong categoryId;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
module transaction;
|
||||||
|
|
||||||
|
public import transaction.data;
|
||||||
|
public import transaction.model;
|
|
@ -8,7 +8,7 @@ import asdf;
|
||||||
/**
|
/**
|
||||||
* Reads a JSON payload into a type T. Throws an `HttpStatusException` if
|
* 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
|
* 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:
|
* Params:
|
||||||
* ctx = The request context to read from.
|
* ctx = The request context to read from.
|
||||||
* Returns: The data that was read.
|
* Returns: The data that was read.
|
||||||
|
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
|
@ -21,6 +21,23 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result
|
||||||
return Optional!T.of(resultMapper(result.front));
|
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.
|
* Finds a list of records from a database.
|
||||||
* Params:
|
* Params:
|
||||||
|
@ -67,6 +84,19 @@ int update(Args...)(Database db, string query, Args args) {
|
||||||
return db.changes();
|
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
|
* 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,
|
* operations will be committed at once when done. If an exception is thrown,
|
||||||
|
|
|
@ -76,3 +76,15 @@ Future<String> postRegister(LoginCredentials credentials) async {
|
||||||
throw Exception('Registration failed.');
|
throw Exception('Registration failed.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ class FinnowApp extends StatelessWidget with WatchItMixin {
|
||||||
if (newValue.state.authenticated()) {
|
if (newValue.state.authenticated()) {
|
||||||
router.replace('/profiles');
|
router.replace('/profiles');
|
||||||
} else {
|
} else {
|
||||||
while (router.canPop()) {
|
while (router.canPop()) {// Clear the navigation stack.
|
||||||
router.pop();
|
router.pop();
|
||||||
}
|
}
|
||||||
router.pushReplacement('/login');
|
router.pushReplacement('/login');
|
||||||
|
|
|
@ -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<GoRouter>().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'))
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,12 +3,10 @@ import 'package:finnow_app/auth/model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:watch_it/watch_it.dart';
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'login_page.dart';
|
import 'router.dart';
|
||||||
import 'profiles_page.dart';
|
|
||||||
import 'register_page.dart';
|
|
||||||
|
|
||||||
|
/// The global GetIt instance, used to get registered singletons.
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
@ -16,49 +14,15 @@ void main() {
|
||||||
runApp(const FinnowApp());
|
runApp(const FinnowApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initializes some resources before we start the app.
|
||||||
void setup() {
|
void setup() {
|
||||||
|
// Register the FinnowApi so it can be called easily from anywhere.
|
||||||
getIt.registerSingleton<FinnowApi>(FinnowApi());
|
getIt.registerSingleton<FinnowApi>(FinnowApi());
|
||||||
|
|
||||||
|
// Register the AuthenticationModel singleton to hold the app's current
|
||||||
|
// authentication information.
|
||||||
getIt.registerSingleton<AuthenticationModel>(AuthenticationModel());
|
getIt.registerSingleton<AuthenticationModel>(AuthenticationModel());
|
||||||
|
|
||||||
|
// Register the GoRouter singleton to provide navigation in the app.
|
||||||
getIt.registerSingleton<GoRouter>(getRouterConfig());
|
getIt.registerSingleton<GoRouter>(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<AuthenticationModel>().state =
|
|
||||||
Unauthenticated(),
|
|
||||||
child: const Text('Logout'))
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
redirect: (context, state) {
|
|
||||||
final bool authenticated =
|
|
||||||
getIt<AuthenticationModel>().state.authenticated();
|
|
||||||
return authenticated ? null : '/login';
|
|
||||||
},
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: '/',
|
|
||||||
redirect: (context, state) {
|
|
||||||
final bool authenticated =
|
|
||||||
getIt<AuthenticationModel>().state.authenticated();
|
|
||||||
return authenticated ? '/profiles' : '/login';
|
|
||||||
}),
|
|
||||||
GoRoute(
|
|
||||||
path: '/profiles',
|
|
||||||
builder: (context, state) => const ProfilesPage(),
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,8 +4,8 @@ import 'package:finnow_app/components/title_text.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'api/auth.dart';
|
import '../api/auth.dart';
|
||||||
import 'main.dart';
|
import '../main.dart';
|
||||||
|
|
||||||
class LoginPage extends StatelessWidget {
|
class LoginPage extends StatelessWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
|
@ -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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import 'package:finnow_app/auth/model.dart';
|
import 'package:finnow_app/auth/model.dart';
|
||||||
|
import 'package:finnow_app/components/profile_list_item.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'api/profile.dart';
|
import '../api/profile.dart';
|
||||||
import 'main.dart';
|
import '../main.dart';
|
||||||
|
|
||||||
class ProfilesPage extends StatelessWidget {
|
class ProfilesPage extends StatelessWidget {
|
||||||
const ProfilesPage({super.key});
|
const ProfilesPage({super.key});
|
||||||
|
@ -11,9 +12,9 @@ class ProfilesPage extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: const Padding(
|
body: const Padding(
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(10),
|
||||||
child: Column(children: [
|
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),
|
SizedBox(height: 10),
|
||||||
Expanded(child: _ProfilesListView()),
|
Expanded(child: _ProfilesListView()),
|
||||||
])),
|
])),
|
||||||
|
@ -39,7 +40,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
return ListView(
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
children: profiles.map((p) => Text('Profile: ${p.name}')).toList(),
|
children: profiles.map(ProfileListItem.new).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<AuthenticationModel>().state = Unauthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<AuthenticationModel>().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<AuthenticationModel>().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<AuthenticationModel>().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<GoRouter>();
|
||||||
|
router.push('/user-account');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.account_circle)
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () =>
|
||||||
|
getIt<AuthenticationModel>().state = Unauthenticated(),
|
||||||
|
icon: const Icon(Icons.logout))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar:
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
|
||||||
|
BackButton(
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter router = getIt<GoRouter>();
|
||||||
|
if (router.canPop()) router.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
GoRouter router = getIt<GoRouter>();
|
||||||
|
router.replace('/profiles');
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.home))
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
|
@ -8,7 +8,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:finnow_app/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
|
Loading…
Reference in New Issue