Files
cloudron-box/dashboard/src/views/ProfileView.vue
T
2025-06-12 13:03:40 +02:00

291 lines
11 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, FormGroup } from 'pankow';
import { TOKEN_TYPES } from '../constants.js';
import NotificationSettings from '../components/NotificationSettings.vue';
import AppPasswords from '../components/AppPasswords.vue';
import SettingsItem from '../components/SettingsItem.vue';
import PrimaryEmailDialog from '../components/dialogs/PrimaryEmailDialog.vue';
import FallbackEmailDialog from '../components/dialogs/FallbackEmailDialog.vue';
import PasswordChangeDialog from '../components/dialogs/PasswordChangeDialog.vue';
import DisableTwoFADialog from '../components/dialogs/DisableTwoFADialog.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({});
const user = ref({});
const inputDialog = useTemplateRef('inputDialog');
const primaryEmailDialog = useTemplateRef('primaryEmailDialog');
const fallbackEmailDialog = useTemplateRef('fallbackEmailDialog');
const passwordChangeDialog = useTemplateRef('passwordChangeDialog');
const disableTwoFADialog = useTemplateRef('disableTwoFADialog');
// 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();
}
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();
}
function onChangeEmail(currentEmail) {
primaryEmailDialog.value.open(currentEmail);
}
async function onChangeFallbackEmail(currentFallbackEmail) {
fallbackEmailDialog.value.open(currentFallbackEmail);
}
async function onAvatarSubmit(file) {
await profileModel.setAvatar(file);
await refreshProfile();
}
async function onPasswordChange() {
passwordChangeDialog.value.open();
}
// 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() {
disableTwoFADialog.value.open();
}
// Init
onMounted(async () => {
let [error, result] = await dashboardModel.config();
if (error) return console.error(error);
config.value = result;
await refreshProfile();
[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 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" />
<PrimaryEmailDialog ref="primaryEmailDialog" @success="refreshProfile"/>
<FallbackEmailDialog ref="fallbackEmailDialog" @success="refreshProfile"/>
<PasswordChangeDialog ref="passwordChangeDialog" @success="refreshProfile"/>
<DisableTwoFADialog ref="disableTwoFADialog" @success="refreshProfile"/>
<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; gap: 20px">
<div style="width: 128px;">
<ImagePicker :src="user.avatarUrl" fallback-src="/img/avatar-default-symbolic.svg" :disabled="config.profileLocked" :size="512" :save-handler="onAvatarSubmit" display-width="128px"/>
</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.username && !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.username && !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.username && !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>
<NotificationSettings v-if="user.isAtLeastAdmin"/>
<AppPasswords/>
<ApiTokens v-if="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>