Fixup password reset view

This commit is contained in:
Johannes Zellner
2025-03-25 18:05:29 +01:00
parent d8373bc488
commit 0cffd76296
6 changed files with 144 additions and 121 deletions
+4 -4
View File
@@ -92,10 +92,11 @@ export default {
</script>
<template>
<div class="layout-root" v-show="ready">
<div class="layout-left">
<img width="128" height="128" class="icon" :src="iconUrl"/>
<div class="layout-root" v-if="ready">
<div class="layout-left" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
<img width="128" height="128" class="icon" :src="`${API_ORIGIN}/api/v1/cloudron/avatar`"/>
</div>
<div class="layout-right">
<small>{{ $t('login.loginTo') }}</small>
<h1>{{ name }}</h1>
@@ -154,7 +155,6 @@ export default {
.layout-left {
background-color: rgba(0,0,0,0.1);
background-image: url('/api/v1/cloudron/background');
background-size: cover;
background-position: center;
flex-basis: 30%;
+121 -107
View File
@@ -1,113 +1,123 @@
<script>
<script setup>
import { ref, onMounted } from 'vue';
import { marked } from 'marked';
import { Button, PasswordInput, TextInput, fetcher } from 'pankow';
import { API_ORIGIN } from '../constants.js';
export default {
name: 'PasswordReset',
components: {
Button,
PasswordInput,
TextInput
},
data() {
return {
ready: false,
busy: false,
error: '',
footer: '',
mode: '',
resetToken: '',
passwordResetIdentifier: '',
newPassword: '',
newPasswordRepeat: '',
totpToken: ''
};
},
async mounted() {
const search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
const ready = ref(false);
const busy = ref(false);
const error = ref({});
const mode = ref('');
const footer = ref('');
const cloudronName = ref('');
const resetToken = ref('');
const passwordResetIdentifier = ref('');
const newPassword = ref('');
const newPasswordRepeat = ref('');
const totpToken = ref('');
try {
const res = await fetcher.get(`${API_ORIGIN}/api/v1/auth/branding`);
this.footer = marked.parse(res.body.footer);
} catch (error) {
console.error('Failed to get branding info.', error);
}
// Init into the correct view
if (search.resetToken) {
this.resetToken = search.resetToken;
window.document.title = 'Set New Password';
this.mode = 'newPassword';
setTimeout(() => document.getElementById('inputNewPassword').focus(), 200);
} else if (search.accessToken || search.access_token) { // auto-login feature
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else { // also search.passwordReset
window.document.title = 'Password Reset Request';
this.mode = 'passwordReset';
this.passwordResetIdentifier = '';
setTimeout(() => document.getElementById('inputPasswordResetIdentifier').focus(), 200);
}
this.ready = true;
},
methods: {
async onPasswordReset() {
this.busy = true;
try {
await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset_request`, { identifier: this.passwordResetIdentifier });
} catch (error) {
console.error('Failed to reset password.', error);
}
this.busy = 'false';
this.mode = 'passwordResetDone';
},
async onNewPassword() {
this.busy = true;
const data = {
resetToken: this.resetToken,
password: this.newPassword,
totpToken: this.totpToken
};
try {
const res = await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset`, data);
if (res.status === 400 || res.status === 401) {
this.error = res.body.message;
this.newPasswordRepeat = '';
} else if (res.status === 409) {
this.error = 'Ask your admin for an invite link first';
} else if (res.status === 202) {
// set token to autologin
localStorage.token = data.accessToken;
this.mode = 'newPasswordDone';
}
} catch (error) {
console.error('Failed to set new password.', error);
}
this.busy = false;
}
}
const MODE = {
NEW_PASSWORD: 'newPassword',
NEW_PASSWORD_DONE: 'newPasswordDone',
RESET_PASSWORD: 'passwordReset',
RESET_PASSWORD_DONE: 'passwordResetDone',
};
async function onPasswordReset() {
busy.value = true;
error.value = {};
try {
await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset_request`, { identifier: passwordResetIdentifier.value });
} catch (error) {
error.value.generic = error;
console.error('Failed to reset password.', error);
}
busy.value = 'false';
mode.value = MODE.RESET_PASSWORD_DONE;
}
async function onNewPassword() {
busy.value = true;
error.value = {};
const data = {
resetToken: resetToken.value,
password: newPassword.value,
totpToken: totpToken.value
};
try {
const res = await fetcher.post(`${API_ORIGIN}/api/v1/auth/password_reset`, data);
if (res.status === 400 || res.status === 401) {
if (res.body.message.indexOf('totpToken') !== -1) {
error.value.totpToken = res.body.message;
totpToken.value = '';
} else {
error.value.generic = res.body.message;
newPasswordRepeat.value = '';
}
} else if (res.status === 409) {
error.value.generic = 'Ask your admin for an invite link first';
} else if (res.status === 202) {
// set token to autologin
localStorage.token = res.body.accessToken;
mode.value = MODE.NEW_PASSWORD_DONE;
}
} catch (error) {
error.value.generic = 'Internal error';
console.error('Failed to set new password.', error);
}
busy.value = false;
}
onMounted(async () => {
const search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
try {
const res = await fetcher.get(`${API_ORIGIN}/api/v1/auth/branding`);
footer.value = marked.parse(res.body.footer);
cloudronName.value = res.body.cloudronName;
} catch (error) {
console.error('Failed to get branding info.', error);
}
// Init into the correct view
if (search.resetToken) {
resetToken.value = search.resetToken;
window.document.title = 'Set New Password';
mode.value = MODE.NEW_PASSWORD;
setTimeout(() => document.getElementById('inputNewPassword').focus(), 200);
} else if (search.accessToken || search.access_token) { // auto-login feature
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else { // also search.passwordReset
window.document.title = 'Password Reset Request';
mode.value = MODE.RESET_PASSWORD;
passwordResetIdentifier.value = '';
setTimeout(() => document.getElementById('inputPasswordResetIdentifier').focus(), 200);
}
ready.value = true;
});
</script>
<template>
<div class="layout-root">
<div class="layout-left">
<img width="128" height="128" class="icon" :src="'/api/v1/cloudron/avatar'"/>
<div class="layout-root" v-if="ready">
<div class="layout-left" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
<img width="128" height="128" class="icon" :src="`${API_ORIGIN}/api/v1/cloudron/avatar`"/>
</div>
<div class="layout-right">
<div v-show="mode === 'passwordReset'">
<h2>{{ $t('passwordReset.title') }}</h2>
<div v-if="mode === MODE.RESET_PASSWORD">
<small>{{ $t('passwordReset.title') }}</small>
<h1>{{ cloudronName }}</h1>
<br/>
<form name="passwordResetForm" @submit.prevent="onPasswordReset()">
<input type="submit" style="display: none;"/>
@@ -122,34 +132,37 @@ export default {
</form>
</div>
<div v-show="mode === 'passwordResetDone'">
<h2 v-show="!error">{{ $t('passwordReset.emailSent.title') }}</h2>
<h4 v-show="error" class="has-error">{{ error }}</h4>
<div v-if="mode === MODE.RESET_PASSWORD_DONE">
<h4 v-if="error.generic" class="has-error">{{ error.generic }}</h4>
<h2 v-else>{{ $t('passwordReset.emailSent.title') }}</h2>
<Button href="/">{{ $t('passwordReset.backToLoginAction') }}</Button>
</div>
<div v-show="mode === 'newPassword'">
<h2>{{ $t('passwordReset.newPassword.title') }}</h2>
<div v-if="mode === MODE.NEW_PASSWORD">
<small>{{ $t('passwordReset.newPassword.title') }}</small>
<h1>{{ cloudronName }}</h1>
<br/>
<p class="has-error" v-show="error">{{ error }}</p>
<p class="has-error" v-if="error.generic">{{ error.generic }}</p>
<form name="newPasswordForm" @submit.prevent="onNewPassword()">
<input type="submit" style="display: none;"/>
<input type="password" style="display: none;"/>
<div class="form-element" :class="{'has-error': newPasswordRepeat && newPassword !== newPasswordRepeat}">
<div class="form-element" :class="{'has-error': newPasswordRepeat && newPassword !== newPasswordRepeat }">
<label for="inputNewPassword">{{ $t('passwordReset.newPassword.password') }}</label>
<PasswordInput id="inputNewPassword" v-model="newPassword" autofocus required />
</div>
<div class="form-element" :class="{'has-error': newPasswordRepeat && newPassword !== newPasswordRepeat}">
<div class="form-element" :class="{'has-error': newPasswordRepeat && newPassword !== newPasswordRepeat }">
<label for="inputNewPasswordRepeat">{{ $t('passwordReset.newPassword.passwordRepeat') }}</label>
<PasswordInput id="inputNewPasswordRepeat" v-model="newPasswordRepeat" required />
</div>
<div class="form-element">
<div class="form-element" :class="{'has-error': error.totpToken }">
<label for="inputPasswordResetTotpToken">{{ $t('login.2faToken') }}</label>
<TextInput id="inputPasswordResetTotpToken" v-model="totpToken" :disabled="busy" />
<p class="has-error" v-if="error.totpToken">{{ error.totpToken }}</p>
</div>
<Button style="margin-top: 12px" @click="onNewPassword()" :disabled="busy || !newPassword || newPassword !== newPasswordRepeat" :loading="busy">{{ $t('passwordReset.passwordChanged.submitAction') }}</Button>
@@ -157,8 +170,10 @@ export default {
</form>
</div>
<div v-show="mode === 'newPasswordDone'">
<h2>{{ $t('passwordReset.success.title') }}</h2>
<div v-if="mode === MODE.NEW_PASSWORD_DONE">
<small>{{ $t('passwordReset.success.title') }}</small>
<h1>{{ cloudronName }}</h1>
<br/>
<Button href="/">{{ $t('passwordReset.success.openDashboardAction') }}</Button>
</div>
</div>
@@ -182,7 +197,6 @@ export default {
.layout-left {
background-color: rgba(0,0,0,0.1);
background-image: url('/api/v1/cloudron/background');
background-size: cover;
background-position: center;
flex-basis: 30%;