2025-01-14 10:27:27 +01:00
|
|
|
<template>
|
|
|
|
|
<div class="content">
|
|
|
|
|
<InputDialog ref="inputDialog" />
|
2025-01-15 12:31:35 +01:00
|
|
|
|
2025-01-14 10:27:27 +01:00
|
|
|
<h1>{{ $t('profile.title') }}</h1>
|
|
|
|
|
<Card>
|
|
|
|
|
<div style="display: flex;">
|
|
|
|
|
<div style="width: 150px;">
|
2025-01-14 14:52:10 +01:00
|
|
|
<input type="file" ref="avatarFileInput" style="display: none" accept="image/*" @change="onAvatarChanged()"/>
|
|
|
|
|
<div class="settings-avatar" :style="`background-image: url('${user.avatarUrl}');`" @click="avatarFileInput.click()">
|
2025-01-14 10:27:27 +01:00
|
|
|
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="flex-grow: 1;">
|
2025-01-15 12:35:47 +01:00
|
|
|
<table style="width: 100%;">
|
2025-01-14 10:27:27 +01:00
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
2025-01-14 11:54:19 +01:00
|
|
|
<td class="text-muted">{{ $t('main.username') }}</td>
|
|
|
|
|
<td style="width: 100px; height: 34px;">{{ user.username }}</td>
|
|
|
|
|
<td style="width: 32px"></td>
|
2025-01-14 10:27:27 +01:00
|
|
|
</tr>
|
|
|
|
|
<tr>
|
2025-01-14 11:54:19 +01:00
|
|
|
<td class="text-muted">{{ $t('main.displayName') }}</td>
|
|
|
|
|
<td style="white-space: nowrap;">{{ user.displayName }}</td>
|
|
|
|
|
<td><Button small tool outline @click="onChangeDisplayName(user.displayName)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
2025-01-14 10:27:27 +01:00
|
|
|
</tr>
|
|
|
|
|
<tr>
|
2025-01-14 11:54:19 +01:00
|
|
|
<td class="text-muted">{{ $t('profile.primaryEmail') }}</td>
|
|
|
|
|
<td style="white-space: nowrap;">{{ user.email }}</td>
|
|
|
|
|
<td><Button small tool outline @click="onChangeEmail(user.email)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
2025-01-14 10:27:27 +01:00
|
|
|
</tr>
|
|
|
|
|
<tr>
|
2025-01-14 11:54:19 +01:00
|
|
|
<td class="text-muted">{{ $t('profile.passwordRecoveryEmail') }}</td>
|
|
|
|
|
<td style="white-space: nowrap;">{{ user.fallbackEmail }}</td>
|
|
|
|
|
<td><Button small tool outline @click="onChangeFallbackEmail(user.fallbackEmail)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
2025-01-14 10:27:27 +01:00
|
|
|
</tr>
|
|
|
|
|
<tr>
|
2025-01-14 11:54:19 +01:00
|
|
|
<td class="text-muted">{{ $t('profile.language') }}</td>
|
|
|
|
|
<td colspan="2" class="text-right"><Dropdown small tool outline v-model="language" :options="languages" option-label="display" option-key="id" @select="onSelectLanguage"/></td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr v-show="!user.source">
|
2025-01-14 14:52:10 +01:00
|
|
|
<td colspan="3" class="text-right">
|
|
|
|
|
<!-- <Button tool @click="onPasswordReset()">{{ $t('profile.passwordResetAction') }}</Button> -->
|
|
|
|
|
<Button tool @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
|
|
|
|
|
<Button tool v-show="!user.source && !config.external2FA" @click="on2FactorAuthConfig()">{{ $t(user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
|
2025-01-15 12:31:35 +01:00
|
|
|
<Button tool @click="profileModel.logout()" icon="fa fa-sign-out">{{ $t('main.logout') }}</Button>
|
2025-01-14 14:52:10 +01:00
|
|
|
</td>
|
2025-01-14 10:27:27 +01:00
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
2025-01-14 15:52:12 +01:00
|
|
|
|
2025-01-15 16:04:42 +01:00
|
|
|
<AppPasswords/>
|
2025-01-15 16:32:21 +01:00
|
|
|
<ApiTokens v-show="user.isAtLeastAdmin"/>
|
2025-01-14 15:52:12 +01:00
|
|
|
|
2025-01-16 12:24:15 +01:00
|
|
|
<h2 class="header">{{ $t('profile.loginTokens.title') }}</h2>
|
2025-01-14 15:52:12 +01:00
|
|
|
<Card>
|
|
|
|
|
<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>
|
|
|
|
|
</Card>
|
2025-01-14 10:27:27 +01:00
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
2025-01-15 12:31:35 +01:00
|
|
|
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
|
|
|
|
|
2025-01-14 10:27:27 +01:00
|
|
|
import { useI18n } from 'vue-i18n';
|
2025-01-15 12:31:35 +01:00
|
|
|
const i18n = useI18n();
|
|
|
|
|
const t = i18n.t;
|
|
|
|
|
|
2025-01-14 10:27:27 +01:00
|
|
|
import { ref, onMounted, useTemplateRef } from 'vue';
|
|
|
|
|
import { Button, Dropdown, InputDialog } from 'pankow';
|
2025-01-14 15:52:12 +01:00
|
|
|
import { TOKEN_TYPES } from '../constants.js';
|
2025-01-15 16:04:42 +01:00
|
|
|
import AppPasswords from './AppPasswords.vue';
|
2025-01-14 10:27:27 +01:00
|
|
|
import Card from './Card.vue';
|
2025-01-15 16:32:21 +01:00
|
|
|
import ApiTokens from './ApiTokens.vue';
|
2025-01-15 12:31:35 +01:00
|
|
|
|
2025-01-14 10:27:27 +01:00
|
|
|
import ProfileModel from '../models/ProfileModel.js';
|
|
|
|
|
import CloudronModel from '../models/CloudronModel.js';
|
2025-01-14 15:52:12 +01:00
|
|
|
import TokensModel from '../models/TokensModel.js';
|
2025-01-14 10:27:27 +01:00
|
|
|
|
|
|
|
|
const profileModel = ProfileModel.create(API_ORIGIN, localStorage.token);
|
|
|
|
|
const cloudronModel = CloudronModel.create(API_ORIGIN, localStorage.token);
|
2025-01-14 15:52:12 +01:00
|
|
|
const tokensModel = TokensModel.create(API_ORIGIN, localStorage.token);
|
2025-01-14 10:27:27 +01:00
|
|
|
|
2025-01-14 15:52:12 +01:00
|
|
|
const config = ref({}); // TODO what is this?
|
2025-01-14 10:27:27 +01:00
|
|
|
const user = ref({});
|
2025-01-14 15:52:12 +01:00
|
|
|
const inputDialog = useTemplateRef('inputDialog');
|
2025-01-15 12:31:35 +01:00
|
|
|
|
|
|
|
|
// Language selector
|
2025-01-14 15:52:12 +01:00
|
|
|
const languages = ref([]);
|
|
|
|
|
const language = ref('');
|
2025-01-14 10:27:27 +01:00
|
|
|
async function onSelectLanguage(lang) {
|
|
|
|
|
window.localStorage.NG_TRANSLATE_LANG_KEY = lang.id;
|
|
|
|
|
|
|
|
|
|
const error = await profileModel.setLanguage(lang.id);
|
|
|
|
|
if (error) console.error('Failed to set language', error);
|
|
|
|
|
else window.location.reload();
|
|
|
|
|
|
|
|
|
|
// TODO dynamically change lange instead of reloading
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-15 12:31:35 +01:00
|
|
|
|
|
|
|
|
// Profile edits
|
2025-01-14 10:27:27 +01:00
|
|
|
async function onChangeDisplayName(currentDisplayName) {
|
|
|
|
|
const displayName = await inputDialog.value.prompt({
|
2025-01-14 11:09:13 +01:00
|
|
|
message: t('profile.changeDisplayName.title'),
|
2025-01-14 10:27:27 +01:00
|
|
|
modal: false,
|
|
|
|
|
value: currentDisplayName,
|
|
|
|
|
confirmStyle: 'success',
|
2025-01-14 11:09:13 +01:00
|
|
|
confirmLabel: t('main.dialog.save'),
|
|
|
|
|
rejectLabel: t('main.dialog.cancel')
|
2025-01-14 10:27:27 +01:00
|
|
|
});
|
|
|
|
|
|
2025-01-14 11:09:13 +01:00
|
|
|
if (!displayName || currentDisplayName === displayName) return;
|
2025-01-14 10:27:27 +01:00
|
|
|
|
|
|
|
|
const error = await profileModel.setDisplayName(displayName);
|
|
|
|
|
if (error) return console.error('Failed to set displayName', error);
|
|
|
|
|
|
|
|
|
|
user.value = await profileModel.get();
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-14 11:09:13 +01:00
|
|
|
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, '' ],
|
|
|
|
|
confirmStyle: 'success',
|
|
|
|
|
confirmLabel: t('main.dialog.save'),
|
|
|
|
|
rejectLabel: t('main.dialog.cancel')
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
user.value = await profileModel.get();
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-14 11:54:19 +01:00
|
|
|
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, '' ],
|
|
|
|
|
confirmStyle: 'success',
|
|
|
|
|
confirmLabel: t('main.dialog.save'),
|
|
|
|
|
rejectLabel: t('main.dialog.cancel')
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
user.value = await profileModel.get();
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-15 12:31:35 +01:00
|
|
|
const avatarFileInput = useTemplateRef('avatarFileInput');
|
|
|
|
|
async function onAvatarChanged() {
|
|
|
|
|
if (!avatarFileInput.value.files[0]) return;
|
|
|
|
|
await profileModel.setAvatar(avatarFileInput.value.files[0]);
|
|
|
|
|
|
|
|
|
|
// invalidate and refresh profile avatar url
|
|
|
|
|
const u = new URL(user.value.avatarUrl);
|
|
|
|
|
u.searchParams.set('ts', Date.now());
|
|
|
|
|
user.value.avatarUrl = u.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Password changes
|
2025-01-14 14:52:10 +01:00
|
|
|
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,
|
|
|
|
|
confirmStyle: 'success',
|
|
|
|
|
confirmLabel: t('main.dialog.save'),
|
|
|
|
|
rejectLabel: t('main.dialog.cancel')
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-14 11:54:19 +01:00
|
|
|
async function onPasswordReset() {
|
|
|
|
|
const error = await profileModel.sendPasswordReset(user.value.email);
|
|
|
|
|
if (error) return console.error('Failed to reset password:', error);
|
|
|
|
|
|
|
|
|
|
window.pankow.notify({ type: 'success', timeout: 5000, text: t('profile.passwordResetNotification.title') + '. ' + t('profile.passwordResetNotification.body', { email: user.value.fallbackEmail || user.value.email }) });
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-14 14:52:10 +01:00
|
|
|
|
2025-01-14 15:52:12 +01:00
|
|
|
// Tokens
|
|
|
|
|
const webadminTokens = ref([]);
|
|
|
|
|
const cliTokens = ref([]);
|
|
|
|
|
const revokeTokensBusy = ref(false);
|
2025-01-15 12:31:35 +01:00
|
|
|
|
2025-01-14 15:52:12 +01:00
|
|
|
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) {
|
2025-01-15 16:04:42 +01:00
|
|
|
const [error] = await tokensModel.remove(token.id);
|
|
|
|
|
if (error) console.error(error);
|
2025-01-14 15:52:12 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await profileModel.logout();
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-15 12:31:35 +01:00
|
|
|
|
|
|
|
|
// Init
|
2025-01-14 15:52:12 +01:00
|
|
|
onMounted(async () => {
|
|
|
|
|
user.value = await profileModel.get();
|
|
|
|
|
|
|
|
|
|
const langs = await cloudronModel.languages();
|
|
|
|
|
languages.value = langs.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;
|
|
|
|
|
|
2025-01-15 16:04:42 +01:00
|
|
|
const [error, tokens] = await tokensModel.list();
|
|
|
|
|
if (error) return console.error(error);
|
2025-01-14 15:52:12 +01:00
|
|
|
|
|
|
|
|
// dashboard and development clientIds were issued with 7.5.0
|
|
|
|
|
webadminTokens.value = tokens.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_WEBADMIN || c.clientId === TOKEN_TYPES.ID_DEVELOPMENT || c.clientId === 'dashboard' || c.clientId === 'development'; });
|
|
|
|
|
cliTokens.value = tokens.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_CLI; });
|
|
|
|
|
});
|
|
|
|
|
|
2025-01-14 14:52:10 +01:00
|
|
|
|
2025-01-14 10:27:27 +01:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
margin-right: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
</style>
|