From e5ec11af44fff15611e35fac2af94f27809e2e72 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Thu, 6 Mar 2025 12:19:03 +0100 Subject: [PATCH] add member merge frontend modal --- .../Controllers/Api/V1/MemberController.php | 2 + app/Providers/JetstreamServiceProvider.php | 2 + .../Common/Member/MemberMergeModal.vue | 109 +++++++++ .../Member/MemberMoreOptionsDropdown.vue | 15 +- .../Common/Member/MemberTableRow.vue | 17 +- .../Pages/Profile/Partials/ApiTokensForm.vue | 5 +- .../packages/api/src/openapi.json.client.ts | 215 +++++++++++++++++- resources/js/utils/permissions.ts | 4 + 8 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 resources/js/Components/Common/Member/MemberMergeModal.vue diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 8e3a82fd..15fe7283 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -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 { diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 0a4a234c..43434d7a 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -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', diff --git a/resources/js/Components/Common/Member/MemberMergeModal.vue b/resources/js/Components/Common/Member/MemberMergeModal.vue new file mode 100644 index 00000000..73a9f111 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberMergeModal.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue b/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue index 9c3bf419..80d8ab35 100644 --- a/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue +++ b/resources/js/Components/Common/Member/MemberMoreOptionsDropdown.vue @@ -1,16 +1,18 @@ diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue index 3c423a0b..e9db36ee 100644 --- a/resources/js/Components/Common/Member/MemberTableRow.vue +++ b/resources/js/Components/Common/Member/MemberTableRow.vue @@ -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'); +}) + diff --git a/resources/js/Pages/Profile/Partials/ApiTokensForm.vue b/resources/js/Pages/Profile/Partials/ApiTokensForm.vue index 895b7aa4..b9e64527 100644 --- a/resources/js/Pages/Profile/Partials/ApiTokensForm.vue +++ b/resources/js/Pages/Profile/Partials/ApiTokensForm.vue @@ -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, }, }); }, diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts index 97a75837..866b050f 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -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 `null` 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(), + }, ], }, { diff --git a/resources/js/utils/permissions.ts b/resources/js/utils/permissions.ts index 98374e84..b8368960 100644 --- a/resources/js/utils/permissions.ts +++ b/resources/js/utils/permissions.ts @@ -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'); }