291 lines
11 KiB
Vue
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>
|