add member merge frontend modal

This commit is contained in:
Gregor Vostrak
2025-03-06 12:19:03 +01:00
committed by Constantin Graf
parent ab263e725f
commit e5ec11af44
8 changed files with 353 additions and 16 deletions

View File

@@ -138,6 +138,8 @@ class MemberController extends Controller
* @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws \Throwable
*
* @operationId mergeMember
*/
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request): JsonResponse
{

View File

@@ -123,6 +123,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:invite-placeholder',
'members:change-ownership',
'members:make-placeholder',
'members:merge-into',
'members:update',
'members:delete',
'billing',
@@ -174,6 +175,7 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view',
'members:update',
'members:invite-placeholder',
'members:merge-into',
'reports:view',
'reports:create',
'reports:update',

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import DialogModal from '@/packages/ui/src/DialogModal.vue';
import { ref } from 'vue';
import {api, type Member} from '@/packages/api/src';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import MemberCombobox from "@/Components/Common/Member/MemberCombobox.vue";
import {UserIcon, ArrowRightIcon} from "@heroicons/vue/24/solid";
import {Badge} from "@/packages/ui/src";
import { useMutation } from '@tanstack/vue-query';
import {getCurrentOrganizationId} from "@/utils/useUser";
import {useNotificationsStore} from "@/utils/notification";
const { handleApiRequestNotifications, addNotification } = useNotificationsStore();
const show = defineModel('show', { default: false });
const saving = ref(false);
const props = defineProps<{
member: Member;
}>();
const newMember = ref<string>('');
const mergeMember = useMutation({
mutationFn: async (newMemberId: string) => {
const organizationId = getCurrentOrganizationId();
if (organizationId === null) {
throw new Error('No current organization id - create report');
}
return await api.mergeMember({
memberId: newMemberId,
}, {
params: {
organization: organizationId,
member: props.member.id
},
});
},
});
async function submit() {
const newMemberId = newMember.value;
if(newMemberId !== ''){
saving.value = true;
await handleApiRequestNotifications(
() =>
mergeMember.mutateAsync(newMemberId),
'Members successfully merged!',
'There was an error merging the members.',
() => {
show.value = false;
}
);
}
else{
addNotification(
'error',
'Please select a member to merge into.',
);
}
}
</script>
<template>
<DialogModal closeable :show="show" @close="show = false">
<template #title>
<div class="flex space-x-2">
<span> Merge Member </span>
</div>
</template>
<template #content>
<p>Merging the user <strong>{{ member.name }} </strong> into another one will transfer all time entries to the new user. <strong>This cannot be reverted!</strong></p>
<div class="py-5 flex flex-col md:flex-row gap-6 items-center">
<div class="flex-1">
<Badge class="flex w-full text-base text-left space-x-3 px-3 text-text-secondary font-normal cursor py-1.5">
<UserIcon class="relative z-10 w-4 text-muted"></UserIcon>
<div class="flex-1 font-medium truncate">
{{ member.name }}
</div>
</Badge>
</div>
<div>
<ArrowRightIcon class="relative z-10 w-4 text-muted"></ArrowRightIcon>
</div>
<div class="flex-1">
<MemberCombobox
v-model="newMember"
></MemberCombobox>
</div>
</div>
</template>
<template #footer>
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
<PrimaryButton
class="ms-3"
:class="{ 'opacity-25': saving }"
:disabled="saving"
@click="submit()">
Merge Member
</PrimaryButton>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { TrashIcon, PencilSquareIcon } from '@heroicons/vue/20/solid';
import { TrashIcon, PencilSquareIcon, ArrowDownOnSquareStackIcon } from '@heroicons/vue/20/solid';
import type { Member } from '@/packages/api/src';
import { canDeleteMembers, canUpdateMembers } from '@/utils/permissions';
import {canDeleteMembers, canMergeMembers, canUpdateMembers} from '@/utils/permissions';
import MoreOptionsDropdown from '@/packages/ui/src/MoreOptionsDropdown.vue';
const emit = defineEmits<{
delete: [];
edit: [];
merge: [];
}>();
const props = defineProps<{
member: Member;
}>();
</script>
<template>
@@ -36,6 +38,15 @@ const props = defineProps<{
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
<span>Delete</span>
</button>
<button
v-if="props.member.role === 'placeholder' && canMergeMembers()"
:aria-label="'Merge Member ' + props.member.name"
data-testid="member_merge"
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out"
@click="emit('merge')">
<ArrowDownOnSquareStackIcon class="w-5 text-icon-active"></ArrowDownOnSquareStackIcon>
<span>Merge</span>
</button>
</div>
</MoreOptionsDropdown>
</template>

View File

@@ -10,16 +10,18 @@ import { getCurrentOrganizationId } from '@/utils/useUser';
import { useNotificationsStore } from '@/utils/notification';
import { canInvitePlaceholderMembers } from '@/utils/permissions';
import { useMembersStore } from '@/utils/useMembers';
import { ref } from 'vue';
import {computed, ref} from 'vue';
import MemberEditModal from '@/Components/Common/Member/MemberEditModal.vue';
import { getOrganizationCurrencyString } from '@/utils/money';
import { formatCents } from '@/packages/ui/src/utils/money';
import MemberMergeModal from "@/Components/Common/Member/MemberMergeModal.vue";
const props = defineProps<{
member: Member;
}>();
const showEditMemberModal = ref(false);
const showMergeMemberModal = ref(false);
function removeMember() {
useMembersStore().removeMember(props.member.id);
@@ -45,6 +47,11 @@ async function invitePlaceholder(id: string) {
);
}
}
const userHasValidMailAddress = computed(() => {
return !props.member.email.endsWith('@solidtime-import.test');
})
</script>
<template>
@@ -87,7 +94,8 @@ async function invitePlaceholder(id: string) {
<SecondaryButton
v-if="
member.is_placeholder === true &&
canInvitePlaceholderMembers()
canInvitePlaceholderMembers() &&
userHasValidMailAddress
"
size="small"
@click="invitePlaceholder(member.id)"
@@ -96,11 +104,14 @@ async function invitePlaceholder(id: string) {
<MemberMoreOptionsDropdown
:member="member"
@edit="showEditMemberModal = true"
@delete="removeMember"></MemberMoreOptionsDropdown>
@delete="removeMember"
@merge="showMergeMemberModal = true"
></MemberMoreOptionsDropdown>
</div>
<MemberEditModal
v-model:show="showEditMemberModal"
:member="member"></MemberEditModal>
<MemberMergeModal v-model:show="showMergeMemberModal" :member="member"></MemberMergeModal>
</TableRow>
</template>

View File

@@ -45,7 +45,6 @@ async function createApiToken(){
(response) => {
createApiTokenForm.name = '';
displayingToken.value = true;
// @ts-expect-error temporary fix until openapi docs type is fixed
newToken.value = response.data.access_token;
}
);
@@ -117,7 +116,7 @@ const deleteApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.deleteApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
apiToken: apiTokenId,
},
});
},
@@ -130,7 +129,7 @@ const revokeApiTokenMutation = useMutation({
mutationFn: async (apiTokenId: string) => {
return await api.revokeApiToken(undefined, {
params: {
apiTokenId: apiTokenId,
apiToken: apiTokenId,
},
});
},

View File

@@ -5,9 +5,9 @@ const ApiTokenResource = z
.object({
id: z.string(),
name: z.string(),
revoked: z.string(),
scopes: z.string(),
created_at: z.union([z.string(), z.null()]),
revoked: z.boolean(),
scopes: z.array(z.string()),
created_at: z.string(),
expires_at: z.union([z.string(), z.null()]),
})
.passthrough();
@@ -15,7 +15,17 @@ const ApiTokenCollection = z.array(ApiTokenResource);
const ApiTokenStoreRequest = z
.object({ name: z.string().min(1).max(255) })
.passthrough();
const ApiTokenWithAccessTokenResource = z.string();
const ApiTokenWithAccessTokenResource = z
.object({
id: z.string(),
name: z.string(),
revoked: z.boolean(),
scopes: z.array(z.string()),
created_at: z.string(),
expires_at: z.union([z.string(), z.null()]),
access_token: z.string(),
})
.passthrough();
const ClientResource = z
.object({
id: z.string(),
@@ -63,6 +73,10 @@ const MemberUpdateRequest = z
.object({ role: Role, billable_rate: z.union([z.number(), z.null()]) })
.partial()
.passthrough();
const MemberMergeIntoRequest = z
.object({ member_id: z.string() })
.partial()
.passthrough();
const OrganizationResource = z
.object({
id: z.string(),
@@ -80,6 +94,28 @@ const OrganizationUpdateRequest = z
employees_can_see_billable_rates: z.boolean().optional(),
})
.passthrough();
const VersionRequest = z
.object({
version: z.string().max(255),
build: z.string().max(255),
url: z.string().max(255),
})
.passthrough();
const TelemetryRequest = z
.object({
version: z.string().max(255),
build: z.string().max(255),
url: z.string().max(255).url(),
user_count: z.number().int(),
organization_count: z.number().int(),
audit_count: z.number().int(),
project_count: z.number().int(),
project_member_count: z.number().int(),
client_count: z.number().int(),
task_count: z.number().int(),
time_entry_count: z.number().int(),
})
.passthrough();
const ProjectResource = z
.object({
id: z.string(),
@@ -486,8 +522,11 @@ export const schemas = {
MemberResource,
Role,
MemberUpdateRequest,
MemberMergeIntoRequest,
OrganizationResource,
OrganizationUpdateRequest,
VersionRequest,
TelemetryRequest,
ProjectResource,
ProjectStoreRequest,
ProjectUpdateRequest,
@@ -1160,6 +1199,71 @@ const endpoints = makeApi([
},
],
},
{
method: 'post',
path: '/v1/organizations/:organization/member/:member/merge-into',
alias: 'mergeMember',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: z
.object({ member_id: z.string() })
.partial()
.passthrough(),
},
{
name: 'organization',
type: 'Path',
schema: z.string(),
},
{
name: 'member',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 403,
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/organizations/:organization/members',
@@ -3394,6 +3498,58 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
},
],
},
{
method: 'post',
path: '/v1/ping/telemetry',
alias: 'v1.ping.telemetry',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: TelemetryRequest,
},
],
response: z.object({ success: z.boolean() }).passthrough(),
errors: [
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'post',
path: '/v1/ping/version',
alias: 'v1.ping.version',
requestFormat: 'json',
parameters: [
{
name: 'body',
type: 'Body',
schema: VersionRequest,
},
],
response: z.object({ version: z.string() }).passthrough(),
errors: [
{
status: 422,
description: `Validation error`,
schema: z
.object({
message: z.string(),
errors: z.record(z.array(z.string())),
})
.passthrough(),
},
],
},
{
method: 'get',
path: '/v1/public/reports',
@@ -3471,6 +3627,17 @@ Please note that the access token is only shown in this response and cannot be r
.object({ data: ApiTokenWithAccessTokenResource })
.passthrough(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3495,18 +3662,29 @@ Please note that the access token is only shown in this response and cannot be r
},
{
method: 'delete',
path: '/v1/users/me/api-tokens/:apiTokenId',
path: '/v1/users/me/api-tokens/:apiToken',
alias: 'deleteApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiTokenId',
name: 'apiToken',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3517,22 +3695,38 @@ Please note that the access token is only shown in this response and cannot be r
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{
method: 'post',
path: '/v1/users/me/api-tokens/:apiTokenId/revoke',
path: '/v1/users/me/api-tokens/:apiToken/revoke',
alias: 'revokeApiToken',
requestFormat: 'json',
parameters: [
{
name: 'apiTokenId',
name: 'apiToken',
type: 'Path',
schema: z.string(),
},
],
response: z.null(),
errors: [
{
status: 400,
description: `API exception`,
schema: z
.object({
error: z.boolean(),
key: z.string(),
message: z.string(),
})
.passthrough(),
},
{
status: 401,
description: `Unauthenticated`,
@@ -3543,6 +3737,11 @@ Please note that the access token is only shown in this response and cannot be r
description: `Authorization error`,
schema: z.object({ message: z.string() }).passthrough(),
},
{
status: 404,
description: `Not found`,
schema: z.object({ message: z.string() }).passthrough(),
},
],
},
{

View File

@@ -77,6 +77,10 @@ export function canDeleteMembers() {
return currentUserHasPermission('members:delete');
}
export function canMergeMembers() {
return currentUserHasPermission('members:merge-into');
}
export function canInvitePlaceholderMembers() {
return currentUserHasPermission('members:invite-placeholder');
}