Files
cloudron-box/dashboard/src/views/ProfileView.vue
T

396 lines
14 KiB
Vue

<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, SingleSelect, Dialog, InputDialog, TextInput, InputGroup } from 'pankow';
import { TOKEN_TYPES } from '../constants.js';
import AppPasswords from '../components/AppPasswords.vue';
import SettingsItem from '../components/SettingsItem.vue';
import Section from '../components/Section.vue';
import ApiTokens from '../components/ApiTokens.vue';
import ImagePicker from '../components/ImagePicker.vue';
import DashboardModel from '../models/DashboardModel.js';
import ProfileModel from '../models/ProfileModel.js';
import CloudronModel from '../models/CloudronModel.js';
import TokensModel from '../models/TokensModel.js';
const dashboardModel = DashboardModel.create();
const profileModel = ProfileModel.create();
const cloudronModel = CloudronModel.create();
const tokensModel = TokensModel.create();
const config = ref({}); // TODO what is this?
const user = ref({});
const inputDialog = useTemplateRef('inputDialog');
// Language selector
const languages = ref([]);
const language = ref('');
async function onSelectLanguage(lang) {
window.localStorage.NG_TRANSLATE_LANG_KEY = lang;
const error = await profileModel.setLanguage(lang);
if (error) console.error('Failed to set language', error);
else window.location.reload();
// TODO dynamically change lange instead of reloading
}
async function refreshProfile() {
const [error, result] = await profileModel.get();
if (error) return console.error(error);
user.value = result;
}
// Profile edits
async function onChangeDisplayName(currentDisplayName) {
const displayName = await inputDialog.value.prompt({
message: t('profile.changeDisplayName.title'),
modal: false,
value: currentDisplayName,
confirmLabel: t('main.dialog.save'),
confirmStyle: 'primary',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!displayName || currentDisplayName === displayName) return;
const error = await profileModel.setDisplayName(displayName);
if (error) return console.error('Failed to set displayName', error);
await refreshProfile();
}
async function onChangeEmail(currentEmail) {
const result = await inputDialog.value.prompt({
message: [ t('profile.changeEmail.title'), t('profile.changeEmail.password') ],
type: [ 'email', 'password' ],
modal: false,
value: [ currentEmail, '' ],
confirmLabel: t('main.dialog.save'),
confirmStyle: 'primary',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!result || !result[0] || !result[1] || currentEmail === result[0]) return;
const error = await profileModel.setEmail(result[0], result[1]);
if (error) return console.error('Failed to set email', error);
await refreshProfile();
}
async function onChangeFallbackEmail(currentFallbackEmail) {
const result = await inputDialog.value.prompt({
message: [ t('profile.changeFallbackEmail.title'), t('profile.changeEmail.password') ],
type: [ 'email', 'password' ],
modal: false,
value: [ currentFallbackEmail, '' ],
confirmLabel: t('main.dialog.save'),
confirmStyle: 'primary',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!result || !result[1] || currentFallbackEmail === result[0]) return;
const error = await profileModel.setFallbackEmail(result[0], result[1]);
if (error) return console.error('Failed to set fallback email', error);
await refreshProfile();
}
async function onAvatarChanged(file) {
await profileModel.setAvatar(file);
// TODO we somehow need to do this globally
// invalidate and refresh profile avatar url
const u = new URL(user.value.avatarUrl);
u.searchParams.set('ts', Date.now());
user.value.avatarUrl = u.toString();
}
async function onBackgroundChanged(file) {
await profileModel.setBackgroundImage(file);
await refreshProfile();
if (file) {
window.document.body.style.backgroundImage = `url('${user.value.backgroundImageUrl}')`;
window.document.body.classList.add('has-background');
} else {
window.document.body.style.backgroundImage = 'None';
window.document.body.classList.remove('has-background');
}
}
// Password changes
async function onPasswordChange() {
const result = await inputDialog.value.prompt({
message: [ t('profile.changePassword.newPassword'), t('profile.changePassword.newPasswordRepeat'), t('profile.changePassword.currentPassword') ],
type: [ 'password', 'password', 'password' ],
modal: false,
confirmLabel: t('main.dialog.save'),
confirmStyle: 'primary',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!result || !result[0] || !result[1] || !result[2] || result[0] === result[1]) return;
const error = await profileModel.setPassword(result[2], result[0]);
if (error) return console.error('Failed to change password', error);
}
// Tokens
const webadminTokens = ref([]);
const cliTokens = ref([]);
const revokeTokensBusy = ref(false);
async function onRevokeAllWebAndCliTokens() {
revokeTokensBusy.value = true;
// filter current access token to be able to logout still
const tokens = webadminTokens.value.concat(cliTokens.value).filter(t => t.accessToken !== localStorage.token);
for (const token of tokens) {
const [error] = await tokensModel.remove(token.id);
if (error) console.error(error);
}
await profileModel.logout();
}
// 2fa
const mandatory2FAHelp = ref('');
const twoFAModal = ref(false);
const twoFASecret = ref('');
const twoFATotpToken = ref('');
const twoFAQRCode = ref('');
const twoFAEnableError = ref('');
const twoFADialog = useTemplateRef('twoFADialog');
async function onOpenTwoFASetupDialog(modal = false) {
const [error, result] = await profileModel.setTwoFASecret();
if (error) return console.error(error);
twoFAModal.value = modal;
twoFAEnableError.value = '';
twoFATotpToken.value = '';
twoFASecret.value = result.secret;
twoFAQRCode.value = result.qrcode;
twoFADialog.value.open();
}
async function onTwoFAEnable() {
const [error] = await profileModel.enableTwoFA(twoFATotpToken.value);
if (error) {
twoFATotpToken.value = '';
return twoFAEnableError.value = error.body ? error.body.message : 'Internal error';
}
await refreshProfile();
twoFADialog.value.close();
}
async function onTwoFADisable() {
const password = await inputDialog.value.prompt({
message: t('profile.disable2FA.title'),
modal: true,
placeholder: t('appstore.accountDialog.password'),
type: 'password',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no'),
rejectStyle: 'secondary',
});
if (!password) return;
const [error] = await profileModel.disableTwoFA(password);
if (error) return onTwoFADisable();
await refreshProfile();
}
// Init
onMounted(async () => {
await refreshProfile();
let [error, result] = await cloudronModel.languages();
languages.value = result.map(l => {
return {
id: l,
display: t(`lang.${l}`)
};
}).sort((a, b) => {
return a.display.localeCompare(b.display);
});
const usedLang = window.localStorage.NG_TRANSLATE_LANG_KEY || 'en';
language.value = languages.value.find(l => l.id === usedLang).id;
[error, result] = await dashboardModel.config();
if (error) return console.error(error);
config.value = result;
[error, result] = await tokensModel.list();
if (error) return console.error(error);
// dashboard and development clientIds were issued with 7.5.0
webadminTokens.value = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_WEBADMIN || c.clientId === TOKEN_TYPES.ID_DEVELOPMENT || c.clientId === 'dashboard' || c.clientId === 'development'; });
cliTokens.value = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_CLI; });
// check if we should show the 2fa setup
if (window.location.hash.indexOf('setup2fa') !== -1) onOpenTwoFASetupDialog(true /* modal */);
});
</script>
<template>
<div class="content">
<InputDialog ref="inputDialog" />
<Dialog ref="twoFADialog" :title="$t('profile.enable2FA.title')" :show-x="!twoFAModal" :modal="twoFAModal">
<div style="text-align: center; max-width: 420px">
<p v-show="mandatory2FAHelp">{{ $t('profile.enable2FA.description') }}</p>
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
<small>{{ twoFASecret }}</small>
<br/>
<br/>
<p class="has-error" v-show="twoFAEnableError">{{ twoFAEnableError }} </p>
<form @submit.prevent="onTwoFAEnable()">
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
<FormGroup style="text-align: left;">
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
<InputGroup>
<TextInput v-model="twoFATotpToken" id="totpTokenInput" style="flex-grow: 1;"/>
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
</InputGroup>
</FormGroup>
</form>
</div>
</Dialog>
<Section :title="$t('profile.title')">
<template #header-buttons>
<Button @click="profileModel.logout()" icon="fa fa-sign-out">{{ $t('main.logout') }}</Button>
</template>
<div style="display: flex; flex-wrap: wrap">
<div style="width: 150px; display: flex; flex-direction: column; justify-content: space-between;">
<ImagePicker :src="user.avatarUrl" fallback-src="/img/background-image-placeholder.svg" :size="512" @changed="onAvatarChanged" display-width="128px"/>
<div>
<ImagePicker :src="user.backgroundImageUrl" fallback-src="/img/background-image-placeholder.svg" :max-size="1280" @changed="onBackgroundChanged" display-width="128px"/>
<div v-if="user.hasBackgroundImage" class="actionable" @click="onBackgroundChanged(null)">Clear</div>
</div>
</div>
<div style="flex-grow: 1;">
<SettingsItem>
<FormGroup>
<label>{{ $t('main.username') }}</label>
<div>{{ user.username }}</div>
</FormGroup>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('main.displayName') }}</label>
<div>{{ user.displayName }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button tool plain @click="onChangeDisplayName(user.displayName)" v-show="!user.source && !config.profileLocked">{{ $t('main.dialog.edit') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('profile.primaryEmail') }}</label>
<div>{{ user.email }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button tool plain @click="onChangeEmail(user.email)" v-show="!user.source && !config.profileLocked">{{ $t('main.dialog.edit') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
<FormGroup>
<label>{{ $t('profile.passwordRecoveryEmail') }}</label>
<div>{{ user.fallbackEmail || 'unset' }}</div>
</FormGroup>
<div style="display: flex; align-items: center">
<Button tool plain @click="onChangeFallbackEmail(user.fallbackEmail)" v-show="!user.source && !config.profileLocked">{{ $t('main.dialog.edit') }}</Button>
</div>
</SettingsItem>
<SettingsItem>
<div style="display: flex; align-items: center">
<div style="font-weight: bold">{{ $t('profile.language') }}</div>
</div>
<div style="display: flex; align-items: center">
<SingleSelect v-model="language" :options="languages" option-label="display" option-key="id" @select="onSelectLanguage"/>
</div>
</SettingsItem>
<div style="display: flex; gap: 10px;">
<Button tool @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
<Button tool v-show="!user.source && !config.external2FA" @click="user.twoFactorAuthenticationEnabled ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
</div>
</div>
</div>
</Section>
<AppPasswords/>
<ApiTokens v-show="user.isAtLeastAdmin"/>
<Section :title="$t('profile.loginTokens.title')">
<p>{{ $t('profile.loginTokens.description', { webadminTokenCount: webadminTokens.length, cliTokenCount: cliTokens.length }) }}</p>
<Button danger :loading="revokeTokensBusy" :disabled="revokeTokensBusy" @click="onRevokeAllWebAndCliTokens()">{{ $t('profile.loginTokens.logoutAll') }}</Button>
</Section>
</div>
</template>
<style scoped>
.profile-avatar {
position: relative;
cursor: pointer;
width: 128px;
height: 128px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
border: 1px solid gray;
border-radius: 3px;
margin-bottom: 10px;
}
.profile-avatar-edit-indicator {
position: absolute;
bottom: -4px;
right: -4px;
border-radius: 20px;
padding: 5px;
color: var(--pankow-text-color);
background-color: var(--pankow-input-background-color);
transition: all 250ms;
}
.profile-avatar:hover .profile-avatar-edit-indicator {
color: white;
background: var(--pankow-color-primary);
transform: scale(1.2);
}
</style>