2025-02-16 17:04:58 +01:00
|
|
|
<script setup>
|
|
|
|
|
|
|
|
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
|
const i18n = useI18n();
|
|
|
|
|
const t = i18n.t;
|
|
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
import { ref, useTemplateRef, inject } from 'vue';
|
2025-07-10 11:55:11 +02:00
|
|
|
import { Dialog, TextInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from '@cloudron/pankow';
|
2025-02-16 17:04:58 +01:00
|
|
|
import { ROLES } from '../constants.js';
|
2025-06-30 20:48:51 +02:00
|
|
|
import ImagePicker from '../components/ImagePicker.vue';
|
2025-04-23 10:29:31 +02:00
|
|
|
import DashboardModel from '../models/DashboardModel.js';
|
2025-02-16 17:04:58 +01:00
|
|
|
import ProfileModel from '../models/ProfileModel.js';
|
|
|
|
|
import UsersModel from '../models/UsersModel.js';
|
|
|
|
|
import GroupsModel from '../models/GroupsModel.js';
|
|
|
|
|
|
2025-04-23 10:29:31 +02:00
|
|
|
const dashboardModel = DashboardModel.create();
|
2025-02-16 17:04:58 +01:00
|
|
|
const profileModel = ProfileModel.create();
|
|
|
|
|
const usersModel = UsersModel.create();
|
|
|
|
|
const groupsModel = GroupsModel.create();
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits([ 'success' ]);
|
|
|
|
|
|
|
|
|
|
const dialog = useTemplateRef('dialog');
|
2025-09-16 12:05:47 +02:00
|
|
|
const imagePicker = useTemplateRef('imagePicker');
|
2025-04-23 10:29:31 +02:00
|
|
|
const form = useTemplateRef('form');
|
2025-02-16 17:04:58 +01:00
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
const refreshProfile = inject('refreshProfile');
|
|
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
// also determines if new or edit mode
|
|
|
|
|
const user = ref(null);
|
|
|
|
|
const roles = ref([]);
|
|
|
|
|
const profile = ref({});
|
|
|
|
|
const busy = ref(false);
|
2025-04-23 10:29:31 +02:00
|
|
|
const profileLocked = ref(false);
|
2025-06-04 10:48:57 +02:00
|
|
|
const external2FA = ref(false);
|
2025-02-16 17:04:58 +01:00
|
|
|
const formError = ref({});
|
|
|
|
|
const displayName = ref('');
|
|
|
|
|
const email = ref('');
|
|
|
|
|
const fallbackEmail = ref('');
|
2025-06-30 20:48:51 +02:00
|
|
|
const avatarUrl = ref('');
|
2025-02-16 17:04:58 +01:00
|
|
|
const username = ref('');
|
|
|
|
|
const role = ref('');
|
|
|
|
|
const groups = ref([]);
|
|
|
|
|
const localGroups = ref([]);
|
|
|
|
|
const allGroups = ref([]);
|
|
|
|
|
const allLocalGroups = ref([]);
|
|
|
|
|
const active = ref(true);
|
|
|
|
|
const sendInvite = ref(false);
|
2025-03-28 10:22:51 +01:00
|
|
|
const isSelf = ref(false);
|
2025-06-04 10:48:57 +02:00
|
|
|
const reset2FABusy = ref(false);
|
|
|
|
|
|
|
|
|
|
async function onReset2FA() {
|
|
|
|
|
if (!user.value) return;
|
|
|
|
|
|
|
|
|
|
reset2FABusy.value = true;
|
|
|
|
|
|
|
|
|
|
const [error] = await usersModel.disableTwoFactorAuthentication(user.value.id);
|
|
|
|
|
if (error) return console.error(error);
|
|
|
|
|
|
|
|
|
|
user.value.twoFactorAuthenticationEnabled = false;
|
|
|
|
|
reset2FABusy.value = false;
|
|
|
|
|
}
|
2025-02-16 17:04:58 +01:00
|
|
|
|
2025-09-17 11:12:00 +02:00
|
|
|
let avatarFile = 'src';
|
2025-09-16 12:05:47 +02:00
|
|
|
function onAvatarChanged(file) {
|
|
|
|
|
avatarFile = file;
|
2025-06-30 20:48:51 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
async function onSubmit() {
|
2025-04-23 10:29:31 +02:00
|
|
|
if (!form.value.reportValidity()) return;
|
|
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
busy.value = true;
|
|
|
|
|
formError.value = {};
|
|
|
|
|
|
|
|
|
|
const data = {
|
|
|
|
|
email: email.value,
|
|
|
|
|
fallbackEmail: fallbackEmail.value,
|
|
|
|
|
displayName: displayName.value,
|
|
|
|
|
role: role.value
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-28 10:22:51 +01:00
|
|
|
let userId = user.value ? user.value.id : null;
|
|
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
// can only be set not updated
|
2025-09-16 12:05:47 +02:00
|
|
|
if (!user.value || !user.value.username) data.username = username.value || null;
|
2025-02-16 17:04:58 +01:00
|
|
|
|
2025-04-23 10:29:31 +02:00
|
|
|
const isExternal = user.value && user.value.source;
|
|
|
|
|
|
|
|
|
|
// update or add
|
|
|
|
|
if (!isExternal) {
|
2025-03-28 10:22:51 +01:00
|
|
|
let error, result;
|
2025-04-23 10:29:31 +02:00
|
|
|
if (user.value) [error] = await usersModel.update(user.value.id, data);
|
2025-03-28 10:22:51 +01:00
|
|
|
else [error, result] = await usersModel.add(data);
|
|
|
|
|
if (error) {
|
|
|
|
|
const message = error.body ? error.body.message : '';
|
|
|
|
|
if (error.status === 409) {
|
|
|
|
|
if (message.toLowerCase().indexOf('email') !== -1) {
|
|
|
|
|
formError.value.email = 'Email already taken';
|
|
|
|
|
} else if (message.toLowerCase().indexOf('username') !== -1 || message.toLowerCase().indexOf('mailbox') !== -1) {
|
|
|
|
|
formError.value.username = 'Username already taken';
|
|
|
|
|
} else {
|
|
|
|
|
// should not happen!!
|
|
|
|
|
console.error(message);
|
|
|
|
|
}
|
|
|
|
|
} else if (error.status === 400) {
|
|
|
|
|
if (message.toLowerCase().indexOf('email') !== -1) {
|
|
|
|
|
formError.value.email = 'Invalid Email';
|
|
|
|
|
formError.value.emailAttempted = email.value;
|
|
|
|
|
} else if (message.toLowerCase().indexOf('username') !== -1) {
|
|
|
|
|
formError.value.username = message;
|
|
|
|
|
} else {
|
|
|
|
|
// should not happen!!
|
|
|
|
|
console.error(error);
|
|
|
|
|
}
|
2025-02-16 17:04:58 +01:00
|
|
|
} else {
|
|
|
|
|
console.error(error);
|
|
|
|
|
}
|
2025-03-28 10:22:51 +01:00
|
|
|
busy.value = false;
|
|
|
|
|
return;
|
2025-02-16 17:04:58 +01:00
|
|
|
}
|
|
|
|
|
|
2025-04-23 10:29:31 +02:00
|
|
|
userId = user.value ? user.value.id : result.id;
|
2025-03-28 10:22:51 +01:00
|
|
|
}
|
2025-02-16 17:04:58 +01:00
|
|
|
|
2025-03-28 10:22:51 +01:00
|
|
|
// for some reason only user adding supports role setting directly, but not user edit, so we have to do this separately
|
|
|
|
|
if (user.value && profile.value.id !== userId) {
|
2025-02-16 17:04:58 +01:00
|
|
|
const [error] = await usersModel.setRole(userId, role.value);
|
|
|
|
|
if (error) {
|
2025-03-28 10:22:51 +01:00
|
|
|
formError.value.role = error.body ? error.body.message : 'Internal error';
|
2025-02-16 17:04:58 +01:00
|
|
|
busy.value = false;
|
|
|
|
|
return console.error(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-28 10:22:51 +01:00
|
|
|
if (profile.value.id !== userId) {
|
|
|
|
|
const [error] = await usersModel.setActive(userId, active.value);
|
|
|
|
|
if (error) {
|
|
|
|
|
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
|
|
|
|
busy.value = false;
|
|
|
|
|
return console.error(error);
|
|
|
|
|
}
|
2025-02-16 17:04:58 +01:00
|
|
|
}
|
|
|
|
|
|
2025-03-17 19:02:41 +01:00
|
|
|
const [groupError] = await usersModel.setLocalGroups(userId, localGroups.value);
|
2025-02-16 17:04:58 +01:00
|
|
|
if (groupError) {
|
2025-03-28 10:22:51 +01:00
|
|
|
formError.value.generic = groupError.body ? groupError.body.message : 'Internal error';
|
2025-02-16 17:04:58 +01:00
|
|
|
busy.value = false;
|
2025-03-28 10:22:51 +01:00
|
|
|
return console.error(groupError);
|
2025-02-16 17:04:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sendInvite.value) {
|
|
|
|
|
const [error] = await usersModel.sendInviteEmail(userId, email.value);
|
|
|
|
|
if (error) {
|
2025-03-28 10:22:51 +01:00
|
|
|
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
2025-02-16 17:04:58 +01:00
|
|
|
busy.value = false;
|
|
|
|
|
return console.error(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
let avatarError = null;
|
|
|
|
|
if (avatarFile === 'fallback') { // user reset the icon
|
|
|
|
|
[avatarError] = await usersModel.unsetAvatar(userId);
|
|
|
|
|
if (isSelf.value) await refreshProfile();
|
|
|
|
|
} else if (avatarFile !== 'src') { // user loaded custom icon
|
|
|
|
|
[avatarError] = await usersModel.setAvatar(userId, avatarFile);
|
|
|
|
|
if (isSelf.value) await refreshProfile();
|
|
|
|
|
}
|
|
|
|
|
if (avatarError) {
|
|
|
|
|
formError.value.generic = avatarError.body ? avatarError.body.message : 'Internal error';
|
2025-06-30 20:48:51 +02:00
|
|
|
busy.value = false;
|
2025-09-16 12:05:47 +02:00
|
|
|
return console.error(avatarError);
|
2025-06-30 20:48:51 +02:00
|
|
|
}
|
|
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
emit('success');
|
2025-09-16 12:05:47 +02:00
|
|
|
|
2025-02-16 17:04:58 +01:00
|
|
|
dialog.value.close();
|
|
|
|
|
busy.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
defineExpose({
|
|
|
|
|
async open(u = null) {
|
2025-10-08 12:38:19 +02:00
|
|
|
u = u ? JSON.parse(JSON.stringify(u)) : null; // make a copy
|
2025-02-16 17:04:58 +01:00
|
|
|
busy.value = false;
|
|
|
|
|
formError.value = {};
|
|
|
|
|
user.value = u;
|
|
|
|
|
displayName.value = u ? u.displayName : '';
|
|
|
|
|
email.value = u ? u.email : '';
|
|
|
|
|
fallbackEmail.value = u ? u.fallbackEmail : '';
|
|
|
|
|
username.value = u ? u.username : '';
|
|
|
|
|
role.value = u ? u.role : ROLES.USER;
|
|
|
|
|
sendInvite.value = false;
|
|
|
|
|
active.value = u ? u.active : true;
|
2025-09-16 12:05:47 +02:00
|
|
|
avatarUrl.value = u ? `${u.avatarUrl}&ts=${Date.now()}` : ''; // there is already access token in query param
|
|
|
|
|
avatarFile = u?.avatarUrl ? 'src' : 'fallback';
|
2025-02-16 17:04:58 +01:00
|
|
|
|
|
|
|
|
let [error, result] = await groupsModel.list();
|
|
|
|
|
if (error) return console.error(error);
|
|
|
|
|
result.forEach(g => g.label = g.name);
|
|
|
|
|
allGroups.value = result;
|
|
|
|
|
allLocalGroups.value = result.filter(g => !g.source);
|
2025-03-17 19:02:41 +01:00
|
|
|
groups.value = u ? u.groupIds : [];
|
|
|
|
|
localGroups.value = (u ? u.groupIds.filter(g => !g.source) : []);
|
2025-02-16 17:04:58 +01:00
|
|
|
|
|
|
|
|
[error, result] = await profileModel.get();
|
|
|
|
|
if (error) return console.error(error);
|
|
|
|
|
profile.value = result;
|
2025-03-28 20:55:50 +01:00
|
|
|
isSelf.value = u && u.id === profile.value.id;
|
2025-02-16 17:04:58 +01:00
|
|
|
|
|
|
|
|
roles.value = [
|
|
|
|
|
{ id: ROLES.USER, name: t('users.role.user'), disabled: false },
|
|
|
|
|
{ id: ROLES.USER_MANAGER, name: t('users.role.usermanager'), disabled: false },
|
|
|
|
|
{ id: ROLES.MAIL_MANAGER, name: t('users.role.mailmanager'), disabled: false },
|
|
|
|
|
{ id: ROLES.ADMIN, name: t('users.role.admin'), disabled: !profile.value.isAtLeastAdmin },
|
2025-03-28 10:22:51 +01:00
|
|
|
{ id: ROLES.OWNER, name: t('users.role.owner'), disabled: !profile.value.isAtLeastOwner },
|
2025-02-16 17:04:58 +01:00
|
|
|
];
|
|
|
|
|
|
2025-04-23 10:29:31 +02:00
|
|
|
[error, result] = await dashboardModel.config();
|
|
|
|
|
if (error) return console.error(error);
|
|
|
|
|
profileLocked.value = result.profileLocked;
|
2025-06-04 10:48:57 +02:00
|
|
|
external2FA.value = result.external2FA;
|
2025-04-23 10:29:31 +02:00
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
imagePicker.value.reset();
|
2025-02-16 17:04:58 +01:00
|
|
|
dialog.value.open();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<Dialog ref="dialog"
|
2025-11-07 17:49:51 +01:00
|
|
|
:title="user ? $t('users.editUserDialog.title') : $t('users.addUserDialog.title')"
|
2025-02-16 17:04:58 +01:00
|
|
|
:confirm-label="user ? $t('main.dialog.save') : $t('users.addUserDialog.addUserAction')"
|
|
|
|
|
:confirm-busy="busy"
|
|
|
|
|
:confirm-active="!busy"
|
|
|
|
|
reject-style="secondary"
|
2025-09-29 13:53:45 +02:00
|
|
|
:reject-label="$t('main.dialog.cancel')"
|
|
|
|
|
:reject-active="!busy"
|
2025-08-07 20:03:03 +02:00
|
|
|
alternate-style="secondary"
|
2025-06-04 10:48:57 +02:00
|
|
|
:alternate-label="(user && user.twoFactorAuthenticationEnabled && !(user.source && external2FA)) ? $t('users.passwordResetDialog.reset2FAAction') : null"
|
|
|
|
|
:alternate-busy="reset2FABusy"
|
|
|
|
|
@alternate="onReset2FA()"
|
2025-02-16 17:04:58 +01:00
|
|
|
@confirm="onSubmit()"
|
|
|
|
|
>
|
2025-02-16 18:06:08 +01:00
|
|
|
<p class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</p>
|
2025-02-16 17:04:58 +01:00
|
|
|
|
2025-03-28 10:22:51 +01:00
|
|
|
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
|
|
|
|
|
2025-04-23 10:29:31 +02:00
|
|
|
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
2025-02-16 17:04:58 +01:00
|
|
|
<fieldset :disabled="busy">
|
|
|
|
|
<input type="submit" style="display: none;" />
|
|
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
<div style="display: flex; justify-content: center;">
|
2025-07-01 10:34:28 +02:00
|
|
|
<div style="width: 80px;">
|
2025-09-16 12:05:47 +02:00
|
|
|
<ImagePicker ref="imagePicker" mode="simple" :src="avatarUrl" fallback-src="/img/avatar-default-symbolic.svg" :size="512" @changed="onAvatarChanged" display-width="80px"/>
|
2025-07-01 10:34:28 +02:00
|
|
|
</div>
|
2025-06-30 20:48:51 +02:00
|
|
|
</div>
|
|
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
<!-- if profile edit is locked a user has to be set here . username is editable until one is set -->
|
2025-11-07 17:49:51 +01:00
|
|
|
<FormGroup :has-error="formError.username">
|
2025-02-16 17:04:58 +01:00
|
|
|
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
2025-11-07 17:49:51 +01:00
|
|
|
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" :readonly="user?.username ? true : undefined" />
|
|
|
|
|
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
|
2025-03-28 10:22:51 +01:00
|
|
|
<div class="text-danger" v-if="formError.username">{{ formError.username }}</div>
|
2025-02-16 17:04:58 +01:00
|
|
|
</FormGroup>
|
|
|
|
|
|
2025-11-07 17:49:51 +01:00
|
|
|
<FormGroup>
|
|
|
|
|
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
|
|
|
|
<TextInput id="emailInput" v-model="email" :readonly="(user && user.source) ? true : undefined" required />
|
|
|
|
|
<div class="text-danger" v-if="formError.email">{{ formError.email }}</div>
|
|
|
|
|
</FormGroup>
|
|
|
|
|
|
2025-09-16 12:05:47 +02:00
|
|
|
<FormGroup style="flex-grow: 1">
|
|
|
|
|
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
|
2025-11-07 17:49:51 +01:00
|
|
|
<TextInput id="displayNameInput" v-model="displayName" :readonly="(user && user.source) ? true : undefined"/>
|
2025-09-16 12:05:47 +02:00
|
|
|
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
|
|
|
|
|
</FormGroup>
|
|
|
|
|
|
|
|
|
|
<FormGroup>
|
2025-09-29 13:53:45 +02:00
|
|
|
<label for="fallbackEmailInput">{{ $t('users.user.recoveryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
2025-09-16 12:05:47 +02:00
|
|
|
<TextInput id="fallbackEmailInput" v-model="fallbackEmail" />
|
|
|
|
|
<small class="helper-text">{{ $t('users.user.fallbackEmailPlaceholder') }}</small>
|
|
|
|
|
</FormGroup>
|
|
|
|
|
|
2025-03-28 10:22:51 +01:00
|
|
|
<FormGroup v-if="profile.isAtLeastAdmin" :has-error="formError.role">
|
2025-09-29 13:53:45 +02:00
|
|
|
<label for="roleInput">{{ $t('users.user.role') }} <sup><a href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
2025-03-28 10:22:51 +01:00
|
|
|
<SingleSelect id="roleInput" v-model="role" :options="roles" option-key="id" option-label="name" :disabled="isSelf"/>
|
|
|
|
|
<div class="text-danger" v-if="formError.role">{{ formError.role }}</div>
|
2025-02-16 17:04:58 +01:00
|
|
|
</FormGroup>
|
|
|
|
|
|
|
|
|
|
<!-- local groups. they can have local and external users -->
|
|
|
|
|
<FormGroup>
|
|
|
|
|
<label for="groupsInput">{{ $t('users.user.groups') }}</label>
|
|
|
|
|
<div v-if="allGroups.length === 0">{{ $t('users.user.noGroups') }}</div>
|
2025-07-08 14:52:22 +02:00
|
|
|
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
2025-02-16 17:04:58 +01:00
|
|
|
</FormGroup>
|
|
|
|
|
|
2025-11-07 10:45:21 +01:00
|
|
|
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
|
|
|
|
|
<Checkbox v-if="user" v-model="active" :disabled="isSelf" :label="$t('users.user.activeCheckbox')" help-url="https://docs.cloudron.io/user-management/#disable-user"/>
|
2025-02-16 17:04:58 +01:00
|
|
|
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
|
|
|
|
</fieldset>
|
|
|
|
|
</form>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</template>
|