Add user edit/new dialog

This commit is contained in:
Johannes Zellner
2025-02-16 17:04:58 +01:00
parent 270d27be73
commit 740c88c506
4 changed files with 311 additions and 5 deletions

View File

@@ -26,6 +26,7 @@ const allApps = ref([]);
async function onSubmit() {
busy.value = true;
formError.value = '';
if (group.value) {
const [error] = await groupsModel.update(group.value.id, name.value, users.value.map(u => u.id), apps.value.map(u => u.id));
@@ -82,12 +83,12 @@ defineExpose({
@confirm="onSubmit()"
>
<p class="text-warning" v-if="group?.source">{{ $t('users.editGroupDialog.externalLdapWarning') }}</p>
<form @submit.prevent="onSubmit()">
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" />
<FormGroup>
<label for="">{{ $t('users.group.name') }}</label>
<label for="nameInput">{{ $t('users.group.name') }}</label>
<TextInput id="nameInput" v-model="name" />
</FormGroup>

View File

@@ -0,0 +1,220 @@
<script setup>
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from 'pankow';
import { ROLES } from '../constants.js';
import ProfileModel from '../models/ProfileModel.js';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
const profileModel = ProfileModel.create();
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const emit = defineEmits([ 'success' ]);
const dialog = useTemplateRef('dialog');
// also determines if new or edit mode
const user = ref(null);
const roles = ref([]);
const profile = ref({});
const busy = ref(false);
const profileLocked = ref(false); // TODO
const formError = ref({});
const displayName = ref('');
const email = ref('');
const fallbackEmail = ref('');
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);
async function onSubmit() {
busy.value = true;
formError.value = {};
const data = {
email: email.value,
fallbackEmail: fallbackEmail.value,
displayName: displayName.value,
role: role.value
};
// can only be set not updated
if (!user.value) data.username = username.value || null;
let error, result;
if (user.value) [error, result] = await usersModel.update(user.value.id, data);
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);
}
} else {
console.error(error);
}
busy.value = false;
return;
}
const userId = user.value ? user.value.id : result.id;
// TODO edit does not support role setting for some reason
if (user.value) {
const [error] = await usersModel.setRole(userId, role.value);
if (error) {
busy.value = false;
return console.error(error);
}
}
const [activeError] = await usersModel.setActive(userId, active.value);
if (activeError) {
busy.value = false;
return console.error(error);
}
const [groupError] = await usersModel.setLocalGroups(userId, localGroups.value.map(g => g.id));
if (groupError) {
busy.value = false;
return console.error(error);
}
if (sendInvite.value) {
const [error] = await usersModel.sendInviteEmail(userId, email.value);
if (error) {
busy.value = false;
return console.error(error);
}
}
emit('success');
dialog.value.close();
busy.value = false;
}
defineExpose({
async open(u = null) {
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;
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);
groups.value = u ? u.groupIds.map(id => { return result.find(g => g.id === id); }) : [];
localGroups.value = (u ? u.groupIds.map(id => { return result.find(g => g.id === id); }) : []).filter(g => !g.source);
[error, result] = await profileModel.get();
if (error) return console.error(error);
profile.value = result;
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 },
{ id: ROLES.OWNER, name: t('users.role.owner'), disabled: !profile.value.isAtLeastOwner }
];
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="user ? $t('users.editUserDialog.title', { username: (user.username || user.email) }) : $t('users.addUserDialog.title')"
:confirm-label="user ? $t('main.dialog.save') : $t('users.addUserDialog.addUserAction')"
:confirm-busy="busy"
:confirm-active="!busy"
reject-style="secondary"
:reject-label="busy ? null : $t('main.dialog.cancel')"
@confirm="onSubmit()"
>
<div v-if="user && user.source">
<p class="text-warning">{{ $t('users.editUserDialog.externalLdapWarning') }}</p>
<p><label>{{ $t('users.user.displayName') }}</label><br/><TextInput :disabled="true" v-model="user.displayName" /></p>
<p><label>{{ $t('users.user.email') }}</label><br/><TextInput :disabled="true" v-model="user.email" /></p>
</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" />
<FormGroup>
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
<TextInput id="displayNameInput" v-model="displayName" :placeholder="$t('users.user.displayNamePlaceholder')"/>
</FormGroup>
<FormGroup>
<label for="emailInput">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<TextInput id="emailInput" v-model="email" required />
</FormGroup>
<FormGroup>
<label for="fallbackEmailInput">{{ $t('users.user.recoveryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<TextInput id="fallbackEmailInput" v-model="fallbackEmail" :placeholder="$t('users.user.fallbackEmailPlaceholder')" />
</FormGroup>
<FormGroup v-if="!user">
<label for="usernameInput">{{ $t('users.user.username') }}</label>
<TextInput id="usernameInput" v-model="username" :placeholder="profileLocked ? '' : $t('users.user.usernamePlaceholder')" />
</FormGroup>
<FormGroup v-if="profile.isAtLeastAdmin">
<label for="roleInput">{{ $t('users.user.role') }} <sup><a href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="roleInput" v-model="role" :options="roles" option-key="id" option-label="name" />
</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>
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" :options="allLocalGroups" />
</FormGroup>
<br/>
<Checkbox v-model="active" :label="$t('users.user.activeCheckbox')" /><sup><a href="https://docs.cloudron.io/user-management/#disable-user" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
</fieldset>
</form>
</Dialog>
</template>

View File

@@ -31,6 +31,40 @@ function create() {
return [null, users];
},
async add(user) {
const data = {
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
if (user.username) data.username = user.username;
if (user.password) data.password = user.password;
let result;
try {
result = await fetcher.post(`${origin}/api/v1/users`, data, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 201) return [result];
return [null, result.body];
},
async update(id, data) {
let result;
try {
result = await fetcher.post(`${origin}/api/v1/users/${id}/profile`, data, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 204) return [result];
return [null];
},
async remove(id) {
let result;
try {
@@ -42,6 +76,39 @@ function create() {
if (result.status !== 204) return [result];
return [null];
},
async setLocalGroups(id, groupIds) {
let result;
try {
result = await fetcher.put(`${origin}/api/v1/users/${id}/groups`, { groupIds }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 204) return [result];
return [null];
},
async setRole(id, role) {
let result;
try {
result = await fetcher.put(`${origin}/api/v1/users/${id}/role`, { role }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 204) return [result];
return [null];
},
async setActive(id, active) {
let result;
try {
result = await fetcher.put(`${origin}/api/v1/users/${id}/active`, { active }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 204) return [result];
return [null];
},
async setGhost(id, password, expiresAt = 0) {
const data = { password };
@@ -76,6 +143,17 @@ function create() {
return [e];
}
if (result.status !== 202) return [result];
return [null];
},
async sendInviteEmail(id, email) {
let result;
try {
result = await fetcher.post(`${origin}/api/v1/users/${id}/send_invite_email`, { email }, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 202) return [result];
return [null];
},

View File

@@ -8,6 +8,7 @@ import { ref, onMounted, computed, useTemplateRef } from 'vue';
import { Button, ButtonGroup, TextInput, Dropdown, TableView, InputDialog } from 'pankow';
import { ROLES } from '../constants.js';
import Section from '../components/Section.vue';
import UserDialog from '../components/UserDialog.vue';
import GroupDialog from '../components/GroupDialog.vue';
import ImpersonateDialog from '../components/ImpersonateDialog.vue';
import PasswordResetDialog from '../components/PasswordResetDialog.vue';
@@ -49,6 +50,7 @@ const groupsById = ref({});
const roles = ref([]);
const inputDialog = useTemplateRef('inputDialog');
const userDialog = useTemplateRef('userDialog');
const groupDialog = useTemplateRef('groupDialog');
const impersonateDialog = useTemplateRef('impersonateDialog');
const passwordResetDialog = useTemplateRef('passwordResetDialog');
@@ -125,6 +127,10 @@ function onPasswordReset(user) {
passwordResetDialog.value.open(user);
}
function onEditOrAddUser(user = null) {
userDialog.value.open(user);
}
function onEditOrAddGroup(group = null) {
groupDialog.value.open(group);
}
@@ -188,6 +194,7 @@ onMounted(async () => {
<template>
<InputDialog ref="inputDialog" />
<UserDialog ref="userDialog" @success="refreshUsers()"/>
<GroupDialog ref="groupDialog" @success="refreshGroups()"/>
<ImpersonateDialog ref="impersonateDialog" />
<PasswordResetDialog ref="passwordResetDialog" />
@@ -197,10 +204,10 @@ onMounted(async () => {
<template #header-buttons>
<TextInput v-model="search" placeholder="Search ..." />
<Dropdown outline tool :options="filterOptions" option-key="id" option-label="name" v-model="filter"></Dropdown>
<Button icon="fa-solid fa-user-plus">{{ $t('users.newUserAction') }}</Button>
<Button icon="fa-solid fa-user-plus" @click="onEditOrAddUser()">{{ $t('users.newUserAction') }}</Button>
</template>
<TableView :columns="usersColumns" :model="filteredUsers" :busy="busy" style="max-height: 400px;">
<TableView :columns="usersColumns" :model="filteredUsers" :busy="busy" style="max-height: 400px;" @row-click="onEditOrAddUser">
<template #role="user">
<i class="fas fa-crown arrow" v-if="user.active && user.role === 'owner'" v-tooltip="$t('users.users.superadminTooltip')"></i>
<i class="fa fa-user-tie arrow" v-if="user.active && user.role === 'admin'" v-tooltip="$t('users.users.adminTooltip')"></i>
@@ -222,7 +229,7 @@ onMounted(async () => {
<Button small tool secondary :disabled="!canEdit(user)" v-if="!user.inviteAccepted && !isMe(user) && !user.source" @click="invitation.show(user)" v-tooltip="$t('users.users.invitationTooltip')" icon="fa-solid fa-paper-plane" />
<Button small tool secondary :disabled="!canEdit(user)" v-if="user.inviteAccepted && !user.source" @click="onPasswordReset(user)" v-tooltip="$t('users.users.resetPasswordTooltip')" icon="fa-solid fa-key" />
<Button small tool secondary :disabled="!canImpersonate(user)" @click="onImpersonate(user)" v-tooltip="$t('users.users.setGhostTooltip')" icon="fa-solid fa-user-secret" />
<Button small tool secondary :disabled="!canEdit(user)" @click="userEdit.show(user)" v-tooltip="$t('users.users.editUserTooltip')" icon="fa fa-pencil-alt" />
<Button small tool secondary :disabled="!canEdit(user)" @click="onEditOrAddUser(user)" v-tooltip="$t('users.users.editUserTooltip')" icon="fa fa-pencil-alt" />
</ButtonGroup>
<Button small tool danger :disabled="!canEdit(user) || isMe(user)" @click="onRemoveUser(user)" v-tooltip="$t('users.users.removeUserTooltip')" icon="far fa-trash-alt" />
</div>