Added global loader, MyUserPage.
This commit is contained in:
parent
ace83d49ba
commit
4babf39f3d
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -8,4 +8,5 @@ interface UserRepository {
|
|||
User[] findAll();
|
||||
User createUser(string username, string passwordHash);
|
||||
void deleteByUsername(string username);
|
||||
void updatePasswordHash(User user);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import GlobalAlertModal from './components/GlobalAlertModal.vue';
|
||||
import GlobalLoadingOverlay from './components/GlobalLoadingOverlay.vue';
|
||||
</script>
|
||||
<template>
|
||||
<RouterView></RouterView>
|
||||
|
||||
<!-- Global alert modal used by util/alert.ts -->
|
||||
<GlobalAlertModal id="global-alert-modal" />
|
||||
<GlobalLoadingOverlay id="global-loading-overlay" />
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
|
|
@ -25,10 +25,14 @@ export class AuthApiClient extends ApiClient {
|
|||
}
|
||||
|
||||
async deleteMyUser(): Promise<void> {
|
||||
await super.delete('/me')
|
||||
return await super.delete('/me')
|
||||
}
|
||||
|
||||
async getNewToken(): Promise<string> {
|
||||
return await super.getText('/me/token')
|
||||
}
|
||||
|
||||
async changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return await super.postNoResponse('/me/password', { currentPassword, newPassword })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,13 @@ export abstract class ApiClient {
|
|||
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> {
|
||||
await this.doRequest('DELETE', path)
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -55,7 +55,7 @@ async function checkAuth() {
|
|||
</div>
|
||||
<div>
|
||||
<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 class="app-logout-button" @click="authStore.onUserLoggedOut()">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue