api/frontend/src/views/teams/EditTeam.vue
viehlieb ed4da96ab1 feat: assign users to teams via OIDC claims (#1393)
This change adds the ability to sync teams via a custom openid claim. Vikunja will automatically create and delete teams as necessary, it will also add and remove users when they log in. These teams are fully managed by Vikunja and cannot be updated by a user.

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/vikunja#1393
Resolves vikunja/vikunja#1279
Resolves https://github.com/go-vikunja/vikunja/issues/42
Resolves vikunja/vikunja#950
Co-authored-by: viehlieb <pf@pragma-shift.net>
Co-committed-by: viehlieb <pf@pragma-shift.net>
2024-03-02 08:47:10 +00:00

396 lines
9.2 KiB
Vue

<template>
<div
class="loader-container is-max-width-desktop"
:class="{ 'is-loading': teamService.loading }"
>
<card
v-if="userIsAdmin && !team.oidcId"
class="is-fullwidth"
:title="title"
>
<form @submit.prevent="save()">
<div class="field">
<label
class="label"
for="teamtext"
>{{ $t('team.attributes.name') }}</label>
<div class="control">
<input
id="teamtext"
v-model="team.name"
v-focus
:class="{ disabled: teamMemberService.loading }"
:disabled="teamMemberService.loading || undefined"
class="input"
:placeholder="$t('team.attributes.namePlaceholder')"
type="text"
>
</div>
</div>
<p
v-if="showErrorTeamnameRequired && team.name === ''"
class="help is-danger"
>
{{ $t('team.attributes.nameRequired') }}
</p>
<div class="field">
<label
class="label"
for="teamdescription"
>{{ $t('team.attributes.description') }}</label>
<div class="control">
<Editor
id="teamdescription"
v-model="team.description"
:class="{ disabled: teamService.loading }"
:disabled="teamService.loading"
:placeholder="$t('team.attributes.descriptionPlaceholder')"
/>
</div>
</div>
</form>
<div class="field has-addons mt-4">
<div class="control is-fullwidth">
<x-button
:loading="teamService.loading"
class="is-fullwidth"
@click="save()"
>
{{ $t('misc.save') }}
</x-button>
</div>
<div class="control">
<x-button
:loading="teamService.loading"
class="is-danger"
icon="trash-alt"
@click="showDeleteModal = true"
/>
</div>
</div>
</card>
<card
class="is-fullwidth has-overflow"
:title="$t('team.edit.members')"
:padding="false"
>
<div
v-if="userIsAdmin && !team.oidcId"
class="p-4"
>
<div class="field has-addons">
<div class="control is-expanded">
<Multiselect
v-model="newMember"
:loading="userService.loading"
:placeholder="$t('team.edit.search')"
:search-results="foundUsers"
label="username"
@search="findUser"
>
<template #searchResult="{option: user}">
<User
:avatar-size="24"
:user="user"
class="m-0"
/>
</template>
</Multiselect>
</div>
<div class="control">
<x-button
icon="plus"
@click="addUser"
>
{{ $t('team.edit.addUser') }}
</x-button>
</div>
</div>
<p
v-if="showMustSelectUserError"
class="help is-danger"
>
{{ $t('team.edit.mustSelectUser') }}
</p>
</div>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<tbody>
<tr
v-for="m in team?.members"
:key="m.id"
>
<td>
<User
:avatar-size="24"
:user="m"
class="m-0"
/>
</td>
<td>
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<icon icon="lock" />
</span>
{{ $t('team.attributes.admin') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="user" />
</span>
{{ $t('team.attributes.member') }}
</template>
</td>
<td
v-if="userIsAdmin"
class="actions"
>
<x-button
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
class="mr-2"
@click="() => toggleUserType(m)"
>
{{ m.admin ? $t('team.edit.makeMember') : $t('team.edit.makeAdmin') }}
</x-button>
<x-button
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
class="is-danger"
icon="trash-alt"
@click="() => {memberToDelete = m; showUserDeleteModal = true}"
/>
</td>
</tr>
</tbody>
</table>
</card>
<x-button
class="is-fullwidth is-danger"
@click="showLeaveModal = true"
>
{{ $t('team.edit.leave.title') }}
</x-button>
<!-- Leave team modal -->
<modal
v-if="showLeaveModal"
@close="showLeaveModal = false"
@submit="leave()"
>
<template #header>
<span>{{ $t('team.edit.leave.title') }}</span>
</template>
<template #text>
<p>
{{ $t('team.edit.leave.text1') }}<br>
{{ $t('team.edit.leave.text2') }}
</p>
</template>
</modal>
<!-- Team delete modal -->
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteTeam()"
>
<template #header>
<span>{{ $t('team.edit.delete.header') }}</span>
</template>
<template #text>
<p>
{{ $t('team.edit.delete.text1') }}<br>
{{ $t('team.edit.delete.text2') }}
</p>
</template>
</modal>
<!-- User delete modal -->
<modal
:enabled="showUserDeleteModal"
@close="showUserDeleteModal = false"
@submit="deleteMember()"
>
<template #header>
<span>{{ $t('team.edit.deleteUser.header') }}</span>
</template>
<template #text>
<p>
{{ $t('team.edit.deleteUser.text1') }}<br>
{{ $t('team.edit.deleteUser.text2') }}
</p>
</template>
</modal>
</div>
</template>
<script lang="ts" setup>
import {computed, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute, useRouter} from 'vue-router'
import Editor from '@/components/input/AsyncEditor'
import Multiselect from '@/components/input/multiselect.vue'
import User from '@/components/misc/user.vue'
import TeamService from '@/services/team'
import TeamMemberService from '@/services/teamMember'
import UserService from '@/services/user'
import {RIGHTS as Rights} from '@/constants/rights'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import type {ITeam} from '@/modelTypes/ITeam'
import type {IUser} from '@/modelTypes/IUser'
import type {ITeamMember} from '@/modelTypes/ITeamMember'
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const userIsAdmin = computed(() => {
return (
team.value &&
team.value.maxRight &&
team.value.maxRight > Rights.READ
)
})
const userInfo = computed(() => authStore.info)
const teamService = ref<TeamService>(new TeamService())
const teamMemberService = ref<TeamMemberService>(new TeamMemberService())
const userService = ref<UserService>(new UserService())
const team = ref<ITeam>()
const teamId = computed(() => Number(route.params.id))
const memberToDelete = ref<ITeamMember>()
const newMember = ref<IUser>()
const foundUsers = ref<IUser[]>()
const showDeleteModal = ref(false)
const showUserDeleteModal = ref(false)
const showLeaveModal = ref(false)
const showErrorTeamnameRequired = ref(false)
const showMustSelectUserError = ref(false)
const title = ref('')
loadTeam()
async function loadTeam() {
team.value = await teamService.value.get({id: teamId.value})
title.value = t('team.edit.title', {team: team.value?.name})
useTitle(() => title.value)
}
async function save() {
if (team.value?.name === '') {
showErrorTeamnameRequired.value = true
return
}
showErrorTeamnameRequired.value = false
team.value = await teamService.value.update(team.value)
success({message: t('team.edit.success')})
}
async function deleteTeam() {
await teamService.value.delete(team.value)
success({message: t('team.edit.delete.success')})
router.push({name: 'teams.index'})
}
async function deleteMember() {
try {
await teamMemberService.value.delete({
teamId: teamId.value,
username: memberToDelete.value.username,
})
success({message: t('team.edit.deleteUser.success')})
await loadTeam()
} finally {
showUserDeleteModal.value = false
}
}
async function addUser() {
showMustSelectUserError.value = false
if(!newMember.value) {
showMustSelectUserError.value = true
return
}
await teamMemberService.value.create({
teamId: teamId.value,
username: newMember.value.username,
})
newMember.value = null
await loadTeam()
success({message: t('team.edit.userAddedSuccess')})
}
async function toggleUserType(member: ITeamMember) {
// FIXME: direct manipulation
member.admin = !member.admin
member.teamId = teamId.value
const r = await teamMemberService.value.update(member)
for (const tm in team.value.members) {
if (team.value.members[tm].id === member.id) {
team.value.members[tm].admin = r.admin
break
}
}
success({
message: member.admin ?
t('team.edit.madeAdmin') :
t('team.edit.madeMember'),
})
}
async function findUser(query: string) {
if (query === '') {
foundUsers.value = []
return
}
const users = await userService.value.getAll({}, {s: query})
foundUsers.value = users.filter((u: IUser) => u.id !== userInfo.value.id)
}
async function leave() {
try {
await teamMemberService.value.delete({
teamId: teamId.value,
username: userInfo.value.username,
})
success({message: t('team.edit.leave.success')})
await router.push({name: 'home'})
} finally {
showUserDeleteModal.value = false
}
}
</script>
<style lang="scss" scoped>
.card.is-fullwidth {
margin-bottom: 1rem;
.content {
padding: 0;
}
}
</style>