From 4babf39f3d47289075c56e2ba393d6e524b44359 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 7 Aug 2025 19:46:29 -0400 Subject: [PATCH] Added global loader, MyUserPage. --- finnow-api/source/api_mapping.d | 1 + finnow-api/source/auth/api.d | 11 +++ finnow-api/source/auth/data.d | 1 + finnow-api/source/auth/data_impl_fs.d | 8 ++ finnow-api/source/auth/service.d | 13 +++ web-app/src/App.vue | 2 + web-app/src/api/auth.ts | 6 +- web-app/src/api/base.ts | 7 ++ .../src/components/GlobalLoadingOverlay.vue | 45 +++++++++ web-app/src/pages/MyUserPage.vue | 91 +++++++++++++++++++ web-app/src/pages/UserAccountLayout.vue | 2 +- web-app/src/router/index.ts | 6 ++ web-app/src/util/loader.ts | 11 +++ 13 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 web-app/src/components/GlobalLoadingOverlay.vue create mode 100644 web-app/src/pages/MyUserPage.vue create mode 100644 web-app/src/util/loader.ts diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index c47bb31..018e8bc 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -34,6 +34,7 @@ HttpRequestHandler mapApiHandlers() { a.map(HttpMethod.GET, "/me", &getMyUser); a.map(HttpMethod.DELETE, "/me", &deleteMyUser); a.map(HttpMethod.GET, "/me/token", &getNewToken); + a.map(HttpMethod.POST, "/me/password", &changeMyPassword); import profile.api; a.map(HttpMethod.GET, "/profiles", &handleGetProfiles); diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d index 6a3e446..f3e60fd 100644 --- a/finnow-api/source/auth/api.d +++ b/finnow-api/source/auth/api.d @@ -90,3 +90,14 @@ void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response) response.writeBodyString(token); infoF!"Generated token for user: %s"(auth.user.username); } + +struct PasswordChangeRequest { + string currentPassword; + string newPassword; +} + +void changeMyPassword(ref ServerHttpRequest request, ref ServerHttpResponse response) { + AuthContext auth = getAuthContext(request); + PasswordChangeRequest data = readJsonBodyAs!PasswordChangeRequest(request); + changePassword(auth.user, new FileSystemUserRepository(), data.currentPassword, data.newPassword); +} diff --git a/finnow-api/source/auth/data.d b/finnow-api/source/auth/data.d index b62ce38..4551ab8 100644 --- a/finnow-api/source/auth/data.d +++ b/finnow-api/source/auth/data.d @@ -8,4 +8,5 @@ interface UserRepository { User[] findAll(); User createUser(string username, string passwordHash); void deleteByUsername(string username); + void updatePasswordHash(User user); } diff --git a/finnow-api/source/auth/data_impl_fs.d b/finnow-api/source/auth/data_impl_fs.d index 73f82d9..deb5007 100644 --- a/finnow-api/source/auth/data_impl_fs.d +++ b/finnow-api/source/auth/data_impl_fs.d @@ -58,6 +58,14 @@ class FileSystemUserRepository : UserRepository { } } + void updatePasswordHash(User user) { + if (!exists(getUserDataFile(user.username))) throw new Exception("User doesn't exist."); + JSONValue userObj = parseJSON(readText(getUserDataFile(user.username))); + userObj.object["passwordHash"] = JSONValue(user.passwordHash); + string jsonStr = userObj.toPrettyString(); + std.file.write(getUserDataFile(user.username), cast(ubyte[]) jsonStr); + } + private string getUserDir(string username) { return buildPath(this.usersDir, username); } diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d index dbf30d2..080bc2d 100644 --- a/finnow-api/source/auth/service.d +++ b/finnow-api/source/auth/service.d @@ -20,6 +20,19 @@ void deleteUser(User user, UserRepository repo) { repo.deleteByUsername(user.username); } +void changePassword(User user, UserRepository repo, string currentPassword, string newPassword) { + import secured.kdf; + auto verificationResult = verifyPassword(currentPassword, HashedPassword(user.passwordHash), PASSWORD_HASH_PEPPER); + if (verificationResult == VerifyPasswordResult.Failure) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, "Incorrect password."); + } + if (currentPassword == newPassword) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "New password cannot be the same as your current one."); + } + HashedPassword newHash = securePassword(newPassword, PASSWORD_HASH_PEPPER); + repo.updatePasswordHash(User(user.username, newHash.toString())); +} + /** * Generates a new token for a user who's logging in with the given credentials. * Params: diff --git a/web-app/src/App.vue b/web-app/src/App.vue index 5d9fb7a..d4b3de1 100644 --- a/web-app/src/App.vue +++ b/web-app/src/App.vue @@ -1,10 +1,12 @@ diff --git a/web-app/src/api/auth.ts b/web-app/src/api/auth.ts index 3bbcbbc..afd84a0 100644 --- a/web-app/src/api/auth.ts +++ b/web-app/src/api/auth.ts @@ -25,10 +25,14 @@ export class AuthApiClient extends ApiClient { } async deleteMyUser(): Promise { - await super.delete('/me') + return await super.delete('/me') } async getNewToken(): Promise { return await super.getText('/me/token') } + + async changeMyPassword(currentPassword: string, newPassword: string): Promise { + return await super.postNoResponse('/me/password', { currentPassword, newPassword }) + } } diff --git a/web-app/src/api/base.ts b/web-app/src/api/base.ts index 3819d88..07df891 100644 --- a/web-app/src/api/base.ts +++ b/web-app/src/api/base.ts @@ -54,6 +54,13 @@ export abstract class ApiClient { return await r.text() } + protected async postNoResponse( + path: string, + body: object | undefined = undefined, + ): Promise { + await this.doRequest('POST', path, body) + } + protected async delete(path: string): Promise { await this.doRequest('DELETE', path) } diff --git a/web-app/src/components/GlobalLoadingOverlay.vue b/web-app/src/components/GlobalLoadingOverlay.vue new file mode 100644 index 0000000..b4c5065 --- /dev/null +++ b/web-app/src/components/GlobalLoadingOverlay.vue @@ -0,0 +1,45 @@ + + + + diff --git a/web-app/src/pages/MyUserPage.vue b/web-app/src/pages/MyUserPage.vue new file mode 100644 index 0000000..32f5046 --- /dev/null +++ b/web-app/src/pages/MyUserPage.vue @@ -0,0 +1,91 @@ + + diff --git a/web-app/src/pages/UserAccountLayout.vue b/web-app/src/pages/UserAccountLayout.vue index 88cb055..4a2da6b 100644 --- a/web-app/src/pages/UserAccountLayout.vue +++ b/web-app/src/pages/UserAccountLayout.vue @@ -55,7 +55,7 @@ async function checkAuth() {
- Welcome, {{ authStore.state?.username }} + Welcome, {{ authStore.state?.username }} diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 43ac9c6..646bef9 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -8,6 +8,7 @@ import ProfilesPage from '@/pages/ProfilesPage.vue' import { useProfileStore } from '@/stores/profile-store' import AccountPage from '@/pages/AccountPage.vue' import EditAccountPage from '@/pages/forms/EditAccountPage.vue' +import MyUserPage from '@/pages/MyUserPage.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -28,6 +29,11 @@ const router = createRouter({ meta: { title: 'Home' }, beforeEnter: profileSelected, }, + { + path: 'me', + component: async () => MyUserPage, + meta: { title: 'My User' }, + }, { path: 'profiles', component: async () => ProfilesPage, diff --git a/web-app/src/util/loader.ts b/web-app/src/util/loader.ts new file mode 100644 index 0000000..7004975 --- /dev/null +++ b/web-app/src/util/loader.ts @@ -0,0 +1,11 @@ +export function showLoader() { + getLoader().style.display = 'flex' +} + +export function hideLoader() { + getLoader().style.display = 'none' +} + +function getLoader(): HTMLDivElement { + return document.getElementById('global-loading-overlay') as HTMLDivElement +}