Use a shared public view layout component
This commit is contained in:
@@ -1,177 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, PasswordInput, TextInput, fetcher } from 'pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
const ready = ref(false);
|
||||
const busy = ref(false);
|
||||
const passwordError = ref(null);
|
||||
const totpError = ref(null);
|
||||
const internalError = ref(null);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const totpToken = ref('');
|
||||
const totpTokenRequired = ref(false);
|
||||
|
||||
// coming from login.html template
|
||||
const name = window.cloudron.name;
|
||||
const note = window.cloudron.note;
|
||||
const iconUrl = window.cloudron.iconUrl;
|
||||
const footer = window.cloudron.footer;
|
||||
const submitUrl = window.cloudron.submitUrl;
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
passwordError.value = false;
|
||||
totpError.value = false;
|
||||
internalError.value = false;
|
||||
|
||||
const body = {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
totpToken: totpToken.value
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetcher.post(submitUrl, body);
|
||||
if (res.status === 410) {
|
||||
// the oidc login session is old
|
||||
window.location.reload();
|
||||
} else if (res.status === 401) {
|
||||
if (res.body.message.indexOf('totpToken') !== -1) {
|
||||
totpError.value = totpTokenRequired.value; // only set on second try coming from login
|
||||
totpTokenRequired.value = true;
|
||||
totpToken.value = '';
|
||||
setTimeout(() => document.getElementById('inputTotpToken').focus(), 0);
|
||||
} else {
|
||||
password.value = '';
|
||||
passwordError.value = true;
|
||||
document.getElementById('inputPassword').focus();
|
||||
}
|
||||
} else if (res.status === 200 ) {
|
||||
if (res.body.redirectTo) return window.location.href = res.body.redirectTo;
|
||||
console.error('login success but missing redirectTo in data:', res.body);
|
||||
internalError.value = true;
|
||||
} else {
|
||||
internalError.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
internalError.value = true;
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// placed optionally in local storage by setupaccount.js
|
||||
const autoLoginToken = localStorage.cloudronFirstTimeToken;
|
||||
if (autoLoginToken) {
|
||||
try {
|
||||
const res = await fetch.post(submitUrl, { autoLoginToken });
|
||||
localStorage.removeItem('cloudronFirstTimeToken');
|
||||
|
||||
if (res.body.redirectTo) window.location.href = res.body.redirectTo;
|
||||
else console.log('login success but missing redirectTo in data:', res.body);
|
||||
} catch (error) {
|
||||
console.error('Failed to use autologin token', error);
|
||||
}
|
||||
}
|
||||
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO mobile layout -->
|
||||
<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>
|
||||
<br/>
|
||||
<div :html="note"></div>
|
||||
|
||||
<p class="has-error" v-show="passwordError">{{ $t('login.errorIncorrectCredentials') }}</p>
|
||||
<p class="has-error" v-show="internalError">{{ $t('login.errorInternal') }}</p>
|
||||
<p class="has-error" v-show="totpError">{{ $t('login.errorIncorrect2FAToken') }}</p>
|
||||
|
||||
<form @submit.prevent="onSubmit" v-show="!totpTokenRequired">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="form-element">
|
||||
<label for="inputUsername">{{ $t('login.username') }}</label>
|
||||
<TextInput id="inputUsername" v-model="username" autofocus required/>
|
||||
</div>
|
||||
|
||||
<div class="form-element">
|
||||
<label for="inputPassword">{{ $t('login.password') }}</label>
|
||||
<PasswordInput id="inputPassword" v-model="password" required/>
|
||||
</div>
|
||||
|
||||
<Button id="loginSubmitButton" style="margin-top: 12px" @click.prevent="onSubmit" :loading="busy">{{ $t('login.signInAction') }}</Button>
|
||||
<a href="/passwordreset.html" style="margin-left: 10px;">{{ $t('login.resetPasswordAction') }}</a>
|
||||
</form>
|
||||
|
||||
<form @submit.prevent="onSubmit" v-show="totpTokenRequired">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="form-element">
|
||||
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
|
||||
<TextInput id="inputTotpToken" v-model="totpToken"/>
|
||||
</div>
|
||||
|
||||
<Button id="totpTokenSubmitButton" style="margin-top: 12px" type="submit" @click.prevent="onSubmit" :loading="busy">{{ $t('login.signInAction') }}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-show="footer" v-html="footer"></footer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
.icon {
|
||||
margin-bottom: 20%;
|
||||
}
|
||||
|
||||
.layout-root {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-left {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-basis: 30%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-right {
|
||||
padding-left: 20px;
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-element {
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, PasswordInput, TextInput, fetcher } from 'pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
// coming from oidc_error.html server-side rendered
|
||||
const name = window.cloudron.name;
|
||||
const iconUrl = window.cloudron.iconUrl;
|
||||
const backgroundUrl = `${API_ORIGIN}/api/v1/cloudron/background`;
|
||||
const errorMessage = window.cloudron.errorMessage;
|
||||
const footer = window.cloudron.footer;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-root">
|
||||
<div class="layout-left" :style="{ 'background-image': `url(${backgroundUrl})` }">
|
||||
<img width="128" height="128" class="icon" :src="iconUrl"/>
|
||||
</div>
|
||||
|
||||
<div class="layout-right">
|
||||
|
||||
<div>
|
||||
<h2>{{ name }} OpenID Error</h2>
|
||||
<div>{{ errorMessage }}</div>
|
||||
<br/>
|
||||
<Button href="/">{{ $t('passwordReset.backToLoginAction') }}</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-show="footer" v-html="footer"></footer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
.icon {
|
||||
margin-bottom: 20%;
|
||||
}
|
||||
|
||||
.layout-root {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-left {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-basis: 30%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-right {
|
||||
padding-left: 20px;
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, PasswordInput, TextInput, fetcher } from 'pankow';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
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('');
|
||||
|
||||
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" 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-if="mode === MODE.RESET_PASSWORD">
|
||||
<small>{{ $t('passwordReset.title') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
|
||||
<form name="passwordResetForm" @submit.prevent="onPasswordReset()">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="form-element">
|
||||
<label for="inputPasswordResetIdentifier">{{ $t('passwordReset.usernameOrEmail') }}</label>
|
||||
<TextInput id="inputPasswordResetIdentifier" name="passwordResetIdentifier" v-model="passwordResetIdentifier" :disabled="busy" autofocus required />
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 12px" @click="onPasswordReset()" :disabled="busy || !passwordResetIdentifier" :loading="busy">{{ $t('passwordReset.resetAction') }}</Button>
|
||||
<a href="/" style="margin-left: 10px;">{{ $t('passwordReset.backToLoginAction') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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-if="mode === MODE.NEW_PASSWORD">
|
||||
<small>{{ $t('passwordReset.newPassword.title') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
|
||||
<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 }">
|
||||
<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 }">
|
||||
<label for="inputNewPasswordRepeat">{{ $t('passwordReset.newPassword.passwordRepeat') }}</label>
|
||||
<PasswordInput id="inputNewPasswordRepeat" v-model="newPasswordRepeat" required />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a href="/" style="margin-left: 10px;">{{ $t('passwordReset.backToLoginAction') }}</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<footer v-show="footer" v-html="footer"></footer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
.icon {
|
||||
margin-bottom: 20%;
|
||||
}
|
||||
|
||||
.layout-root {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-left {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-basis: 30%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layout-right {
|
||||
padding-left: 20px;
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-element {
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
|
||||
const props = defineProps({
|
||||
footerHtml: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO mobile layout -->
|
||||
<div class="public-page-layout">
|
||||
<div class="public-page-layout-root">
|
||||
<div class="public-page-layout-left" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
|
||||
<img width="128" height="128" style="margin-bottom: 20%;" :src="`${API_ORIGIN}/api/v1/cloudron/avatar`"/>
|
||||
</div>
|
||||
|
||||
<div class="public-page-layout-right">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer v-show="footerHtml" v-html="footerHtml"></footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.public-page-layout {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.public-page-layout-root {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.public-page-layout-left {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
flex-basis: 30%;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
padding-left: 20px;
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user