From 230e11cc8ddd22d3b9e34b78db2ea9ca833d4510 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 8 Apr 2026 01:50:38 +0200 Subject: [PATCH] feat: frontend member management - Leden pagina met VDataTable, rol chips, uitnodigingen sectie - InviteMemberDialog + EditMemberRoleDialog - Publieke acceptatiepagina /invitations/[token] - Router guard uitgebreid met requiresAuth: false support - MemberCollection backend uitgebreid met volledige pending_invitations lijst --- .../Resources/Api/V1/MemberCollection.php | 11 +- ...320000_fix_activity_log_morphs_to_ulid.php | 26 ++ apps/app/components.d.ts | 2 + .../members/EditMemberRoleDialog.vue | 142 +++++++ .../components/members/InviteMemberDialog.vue | 133 +++++++ apps/app/src/composables/api/useMembers.ts | 104 +++++ apps/app/src/navigation/vertical/index.ts | 7 + apps/app/src/pages/invitations/[token].vue | 211 ++++++++++ apps/app/src/pages/organisation/members.vue | 373 ++++++++++++++++++ apps/app/src/plugins/1.router/guards.ts | 2 +- apps/app/src/types/member.ts | 84 ++++ apps/app/typed-router.d.ts | 2 + 12 files changed, 1092 insertions(+), 5 deletions(-) create mode 100644 api/database/migrations/2026_04_08_320000_fix_activity_log_morphs_to_ulid.php create mode 100644 apps/app/src/components/members/EditMemberRoleDialog.vue create mode 100644 apps/app/src/components/members/InviteMemberDialog.vue create mode 100644 apps/app/src/composables/api/useMembers.ts create mode 100644 apps/app/src/pages/invitations/[token].vue create mode 100644 apps/app/src/pages/organisation/members.vue create mode 100644 apps/app/src/types/member.ts diff --git a/api/app/Http/Resources/Api/V1/MemberCollection.php b/api/app/Http/Resources/Api/V1/MemberCollection.php index a541505..295ab6d 100644 --- a/api/app/Http/Resources/Api/V1/MemberCollection.php +++ b/api/app/Http/Resources/Api/V1/MemberCollection.php @@ -24,13 +24,16 @@ final class MemberCollection extends ResourceCollection { $organisation = $request->route('organisation'); + $pendingInvitations = UserInvitation::where('organisation_id', $organisation->id) + ->pending() + ->where('expires_at', '>', now()) + ->get(); + return [ 'meta' => [ 'total_members' => $this->collection->count(), - 'pending_invitations_count' => UserInvitation::where('organisation_id', $organisation->id) - ->pending() - ->where('expires_at', '>', now()) - ->count(), + 'pending_invitations_count' => $pendingInvitations->count(), + 'pending_invitations' => InvitationResource::collection($pendingInvitations), ], ]; } diff --git a/api/database/migrations/2026_04_08_320000_fix_activity_log_morphs_to_ulid.php b/api/database/migrations/2026_04_08_320000_fix_activity_log_morphs_to_ulid.php new file mode 100644 index 0000000..77f5fae --- /dev/null +++ b/api/database/migrations/2026_04_08_320000_fix_activity_log_morphs_to_ulid.php @@ -0,0 +1,26 @@ +string('subject_id', 26)->nullable()->change(); + $table->string('causer_id', 26)->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('activity_log', function (Blueprint $table) { + $table->unsignedBigInteger('subject_id')->nullable()->change(); + $table->unsignedBigInteger('causer_id')->nullable()->change(); + }); + } +}; diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 43bfe24..c581065 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -40,10 +40,12 @@ declare module 'vue' { DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] EditEventDialog: typeof import('./src/components/events/EditEventDialog.vue')['default'] + EditMemberRoleDialog: typeof import('./src/components/members/EditMemberRoleDialog.vue')['default'] EditOrganisationDialog: typeof import('./src/components/organisations/EditOrganisationDialog.vue')['default'] EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default'] ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] + InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default'] Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default'] diff --git a/apps/app/src/components/members/EditMemberRoleDialog.vue b/apps/app/src/components/members/EditMemberRoleDialog.vue new file mode 100644 index 0000000..85a9cd6 --- /dev/null +++ b/apps/app/src/components/members/EditMemberRoleDialog.vue @@ -0,0 +1,142 @@ + + + diff --git a/apps/app/src/components/members/InviteMemberDialog.vue b/apps/app/src/components/members/InviteMemberDialog.vue new file mode 100644 index 0000000..78d56e0 --- /dev/null +++ b/apps/app/src/components/members/InviteMemberDialog.vue @@ -0,0 +1,133 @@ + + + diff --git a/apps/app/src/composables/api/useMembers.ts b/apps/app/src/composables/api/useMembers.ts new file mode 100644 index 0000000..cdb64dc --- /dev/null +++ b/apps/app/src/composables/api/useMembers.ts @@ -0,0 +1,104 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { + AcceptInvitationPayload, + AcceptInvitationResponse, + InvitationDetailResponse, + InviteMemberPayload, + Member, + MemberListResponse, + UpdateMemberRolePayload, +} from '@/types/member' + +interface ApiResponse { + success: boolean + data: T + message?: string +} + +export function useMemberList(orgId: Ref) { + return useQuery({ + queryKey: ['members', orgId], + queryFn: async () => { + const { data } = await apiClient.get(`/organisations/${orgId.value}/members`) + return data + }, + enabled: () => !!orgId.value, + }) +} + +export function useInviteMember(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: InviteMemberPayload) => { + const { data } = await apiClient.post>(`/organisations/${orgId.value}/invite`, payload) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members', orgId] }) + }, + }) +} + +export function useUpdateMemberRole(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ userId, role }: UpdateMemberRolePayload) => { + const { data } = await apiClient.put>(`/organisations/${orgId.value}/members/${userId}`, { role }) + return data.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members', orgId] }) + }, + }) +} + +export function useRemoveMember(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (userId: string) => { + await apiClient.delete(`/organisations/${orgId.value}/members/${userId}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members', orgId] }) + }, + }) +} + +export function useRevokeInvitation(orgId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (invitationId: string) => { + await apiClient.delete(`/organisations/${orgId.value}/invitations/${invitationId}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['members', orgId] }) + }, + }) +} + +export function useInvitationDetail(token: Ref) { + return useQuery({ + queryKey: ['invitation', token], + queryFn: async () => { + const { data } = await apiClient.get>(`/invitations/${token.value}`) + return data.data + }, + enabled: () => !!token.value, + retry: false, + }) +} + +export function useAcceptInvitation() { + return useMutation({ + mutationFn: async ({ token, ...payload }: AcceptInvitationPayload & { token: string }) => { + const { data } = await apiClient.post(`/invitations/${token}/accept`, payload) + return data + }, + }) +} diff --git a/apps/app/src/navigation/vertical/index.ts b/apps/app/src/navigation/vertical/index.ts index f447944..4e29633 100644 --- a/apps/app/src/navigation/vertical/index.ts +++ b/apps/app/src/navigation/vertical/index.ts @@ -17,4 +17,11 @@ export default [ to: { name: "organisation" }, icon: { icon: "tabler-building" }, }, + { + title: "Leden", + to: { name: "organisation-members" }, + icon: { icon: "tabler-users-group" }, + action: "read", + subject: "members", + }, ]; diff --git a/apps/app/src/pages/invitations/[token].vue b/apps/app/src/pages/invitations/[token].vue new file mode 100644 index 0000000..aa9382c --- /dev/null +++ b/apps/app/src/pages/invitations/[token].vue @@ -0,0 +1,211 @@ + + + diff --git a/apps/app/src/pages/organisation/members.vue b/apps/app/src/pages/organisation/members.vue new file mode 100644 index 0000000..9141798 --- /dev/null +++ b/apps/app/src/pages/organisation/members.vue @@ -0,0 +1,373 @@ + + + diff --git a/apps/app/src/plugins/1.router/guards.ts b/apps/app/src/plugins/1.router/guards.ts index 3be7d65..332f89d 100644 --- a/apps/app/src/plugins/1.router/guards.ts +++ b/apps/app/src/plugins/1.router/guards.ts @@ -12,7 +12,7 @@ export function setupGuards(router: Router) { } // Protected pages: redirect to login if not authenticated - if (!isPublic && !authStore.isAuthenticated) { + if (!isPublic && !authStore.isAuthenticated && to.meta.requiresAuth !== false) { return { path: '/login', query: { to: to.fullPath } } } }) diff --git a/apps/app/src/types/member.ts b/apps/app/src/types/member.ts new file mode 100644 index 0000000..5161698 --- /dev/null +++ b/apps/app/src/types/member.ts @@ -0,0 +1,84 @@ +export interface Member { + id: string + name: string + email: string + role: string + avatar: string | null +} + +export interface Invitation { + id: string + email: string + role: string + status: 'pending' | 'accepted' | 'expired' + expires_at: string + created_at: string +} + +export type OrganisationRole = + | 'org_admin' + | 'org_member' + | 'event_manager' + | 'staff_coordinator' + | 'volunteer_coordinator' + +export interface MemberListResponse { + data: Member[] + meta: { + total_members: number + pending_invitations_count: number + pending_invitations: Invitation[] + } +} + +export interface InviteMemberPayload { + email: string + role: OrganisationRole +} + +export interface UpdateMemberRolePayload { + userId: string + role: OrganisationRole +} + +export interface InvitationDetailResponse { + id: string + email: string + role: string + status: 'pending' | 'accepted' | 'expired' + organisation: { + id: string + name: string + } + existing_user: boolean +} + +export interface AcceptInvitationPayload { + name?: string + password?: string + password_confirmation?: string +} + +export interface AcceptInvitationResponse { + success: boolean + data: { + user: { + id: string + name: string + email: string + timezone: string + locale: string + avatar: string | null + organisations: Array<{ + id: string + name: string + slug: string + role: string + }> + app_roles: string[] + permissions: string[] + } + token: string + } + message: string +} diff --git a/apps/app/typed-router.d.ts b/apps/app/typed-router.d.ts index a0d7a81..48d10b7 100644 --- a/apps/app/typed-router.d.ts +++ b/apps/app/typed-router.d.ts @@ -23,7 +23,9 @@ declare module 'vue-router/auto-routes' { 'dashboard': RouteRecordInfo<'dashboard', '/dashboard', Record, Record>, 'events': RouteRecordInfo<'events', '/events', Record, Record>, 'events-id': RouteRecordInfo<'events-id', '/events/:id', { id: ParamValue }, { id: ParamValue }>, + 'invitations-token': RouteRecordInfo<'invitations-token', '/invitations/:token', { token: ParamValue }, { token: ParamValue }>, 'login': RouteRecordInfo<'login', '/login', Record, Record>, 'organisation': RouteRecordInfo<'organisation', '/organisation', Record, Record>, + 'organisation-members': RouteRecordInfo<'organisation-members', '/organisation/members', Record, Record>, } }