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