Added global loader, MyUserPage.

This commit is contained in:
Andrew Lalis 2025-08-07 19:46:29 -04:00
parent ace83d49ba
commit 4babf39f3d
13 changed files with 202 additions and 2 deletions

View File

@ -34,6 +34,7 @@ HttpRequestHandler mapApiHandlers() {
a.map(HttpMethod.GET, "/me", &getMyUser); a.map(HttpMethod.GET, "/me", &getMyUser);
a.map(HttpMethod.DELETE, "/me", &deleteMyUser); a.map(HttpMethod.DELETE, "/me", &deleteMyUser);
a.map(HttpMethod.GET, "/me/token", &getNewToken); a.map(HttpMethod.GET, "/me/token", &getNewToken);
a.map(HttpMethod.POST, "/me/password", &changeMyPassword);
import profile.api; import profile.api;
a.map(HttpMethod.GET, "/profiles", &handleGetProfiles); a.map(HttpMethod.GET, "/profiles", &handleGetProfiles);

View File

@ -90,3 +90,14 @@ void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response)
response.writeBodyString(token); response.writeBodyString(token);
infoF!"Generated token for user: %s"(auth.user.username); 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);
}

View File

@ -8,4 +8,5 @@ interface UserRepository {
User[] findAll(); User[] findAll();
User createUser(string username, string passwordHash); User createUser(string username, string passwordHash);
void deleteByUsername(string username); void deleteByUsername(string username);
void updatePasswordHash(User user);
} }

View File

@ -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) { private string getUserDir(string username) {
return buildPath(this.usersDir, username); return buildPath(this.usersDir, username);
} }

View File

@ -20,6 +20,19 @@ void deleteUser(User user, UserRepository repo) {
repo.deleteByUsername(user.username); 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. * Generates a new token for a user who's logging in with the given credentials.
* Params: * Params:

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import GlobalAlertModal from './components/GlobalAlertModal.vue'; import GlobalAlertModal from './components/GlobalAlertModal.vue';
import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue';
</script> </script>
<template> <template>
<RouterView></RouterView> <RouterView></RouterView>
<!-- Global alert modal used by util/alert.ts --> <!-- Global alert modal used by util/alert.ts -->
<GlobalAlertModal id="global-alert-modal" /> <GlobalAlertModal id="global-alert-modal" />
<GlobalLoadingOverlay id="global-loading-overlay" />
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -25,10 +25,14 @@ export class AuthApiClient extends ApiClient {
} }
async deleteMyUser(): Promise<void> { async deleteMyUser(): Promise<void> {
await super.delete('/me') return await super.delete('/me')
} }
async getNewToken(): Promise<string> { async getNewToken(): Promise<string> {
return await super.getText('/me/token') return await super.getText('/me/token')
} }
async changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
return await super.postNoResponse('/me/password', { currentPassword, newPassword })
}
} }

View File

@ -54,6 +54,13 @@ export abstract class ApiClient {
return await r.text() return await r.text()
} }
protected async postNoResponse(
path: string,
body: object | undefined = undefined,
): Promise<void> {
await this.doRequest('POST', path, body)
}
protected async delete(path: string): Promise<void> { protected async delete(path: string): Promise<void> {
await this.doRequest('DELETE', path) await this.doRequest('DELETE', path)
} }

View File

@ -0,0 +1,45 @@
<!-- A globally-mounted loading overlay controlled by util/loader.ts. -->
<script setup lang="ts">
</script>
<template>
<div class="loading-overlay">
<div class="loader-indicator"></div>
</div>
</template>
<style lang="css">
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loader-indicator {
width: 100px;
height: 5px;
background-color: white;
border-radius: 5px;
animation: 1s ease-in-out 0s loader-movement;
animation-iteration-count: infinite;
}
@keyframes loader-movement {
0% {
margin-left: -50%;
}
50% {
margin-left: 50%;
}
100% {
margin-left: -50%;
}
}
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { AuthApiClient } from '@/api/auth';
import { ApiError } from '@/api/base';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import AppForm from '@/components/form/AppForm.vue';
import FormControl from '@/components/form/FormControl.vue';
import FormGroup from '@/components/form/FormGroup.vue';
import ModalWrapper from '@/components/ModalWrapper.vue';
import { useAuthStore } from '@/stores/auth-store';
import { showAlert, showConfirm } from '@/util/alert';
import { hideLoader, showLoader } from '@/util/loader';
import { ref, useTemplateRef } from 'vue';
const authStore = useAuthStore()
const changePasswordModal = useTemplateRef('changePasswordModal')
const currentPassword = ref('')
const newPassword = ref('')
async function doDeleteUser() {
if (await showConfirm('Are you sure you want to delete your account? All data will be permanently deleted.')) {
const api = new AuthApiClient()
try {
await api.deleteMyUser()
await showAlert('Your user has been deleted. You will now be logged out.')
authStore.onUserLoggedOut()
} catch (err) {
console.error(err)
}
}
}
async function showChangePasswordModal() {
await changePasswordModal.value?.show()
// Reset password vars to avoid data leakage.
currentPassword.value = ''
newPassword.value = ''
}
async function doChangePassword() {
changePasswordModal.value?.close()
showLoader()
const api = new AuthApiClient()
try {
await api.changeMyPassword(currentPassword.value, newPassword.value)
hideLoader()
await showAlert('Password has been updated.')
} catch (err) {
hideLoader()
console.error(err)
if (err instanceof ApiError) {
await showAlert(err.message)
}
}
}
</script>
<template>
<AppPage title="My User">
<p>
You are logged in as <code>{{ authStore.state?.username }}</code>.
</p>
<div style="text-align: right;">
<AppButton @click="showChangePasswordModal()">Change Password</AppButton>
<AppButton @click="doDeleteUser()">Delete My User Account</AppButton>
</div>
<!-- Modal for changing the user's password. -->
<ModalWrapper ref="changePasswordModal">
<template v-slot:default>
<AppForm>
<h2>Change Password</h2>
<FormGroup>
<FormControl label="Current Password">
<input type="password" v-model="currentPassword" minlength="8" />
</FormControl>
</FormGroup>
<FormGroup>
<FormControl label="New Password">
<input type="password" v-model="newPassword" minlength="8" @keydown.enter.prevent="doChangePassword()" />
</FormControl>
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>
<AppButton @click="doChangePassword()">Change</AppButton>
<AppButton button-style="secondary" @click="changePasswordModal?.close()">Cancel</AppButton>
</template>
</ModalWrapper>
</AppPage>
</template>

View File

@ -55,7 +55,7 @@ async function checkAuth() {
</div> </div>
<div> <div>
<span class="app-user-widget"> <span class="app-user-widget">
Welcome, <em>{{ authStore.state?.username }}</em> Welcome, <RouterLink to="/me" style="color: var(--bg-primary)">{{ authStore.state?.username }}</RouterLink>
</span> </span>
<span class="app-logout-button" @click="authStore.onUserLoggedOut()"> <span class="app-logout-button" @click="authStore.onUserLoggedOut()">

View File

@ -8,6 +8,7 @@ import ProfilesPage from '@/pages/ProfilesPage.vue'
import { useProfileStore } from '@/stores/profile-store' import { useProfileStore } from '@/stores/profile-store'
import AccountPage from '@/pages/AccountPage.vue' import AccountPage from '@/pages/AccountPage.vue'
import EditAccountPage from '@/pages/forms/EditAccountPage.vue' import EditAccountPage from '@/pages/forms/EditAccountPage.vue'
import MyUserPage from '@/pages/MyUserPage.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -28,6 +29,11 @@ const router = createRouter({
meta: { title: 'Home' }, meta: { title: 'Home' },
beforeEnter: profileSelected, beforeEnter: profileSelected,
}, },
{
path: 'me',
component: async () => MyUserPage,
meta: { title: 'My User' },
},
{ {
path: 'profiles', path: 'profiles',
component: async () => ProfilesPage, component: async () => ProfilesPage,

View File

@ -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
}