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.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);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
<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()">
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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