From 6343b4a17f96b37568178502159bb7a963724730 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 3 Apr 2026 23:20:41 +0200 Subject: [PATCH] Add invitation frontend: invite modal, pending invitations, no-company view Members Index: - "Invite Member" button opens InviteMemberModal (email + role dropdown) - Pending invitations section shows below members table with cancel buttons - Members store gains inviteMember, fetchPendingInvitations, cancelInvitation CompanySwitcher: - Shows pending invitations greyed out below active companies - Each with Accept/Decline mini-buttons - Accepting refreshes bootstrap and switches to new company NoCompanyView: - Standalone page for users with zero accepted companies - Shows pending invitations with Accept/Decline or "no companies" message - Route: /admin/no-company Invitation Pinia store: - Manages user's own pending invitations (fetchPending, accept, decline) - Bootstrap populates invitations from API response Global store: - Bootstrap action stores pending_invitations from response --- lang/en.json | 3 + resources/scripts/admin/admin-router.js | 9 ++ .../modal-components/InviteMemberModal.vue | 132 ++++++++++++++++++ resources/scripts/admin/stores/global.js | 7 + resources/scripts/admin/stores/invitation.js | 56 ++++++++ resources/scripts/admin/stores/members.js | 58 ++++++++ .../scripts/admin/views/NoCompanyView.vue | 99 +++++++++++++ .../scripts/admin/views/members/Index.vue | 67 ++++++++- .../scripts/components/CompanySwitcher.vue | 62 ++++++++ 9 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 resources/scripts/admin/components/modal-components/InviteMemberModal.vue create mode 100644 resources/scripts/admin/stores/invitation.js create mode 100644 resources/scripts/admin/views/NoCompanyView.vue diff --git a/lang/en.json b/lang/en.json index 2df4079a..c77a010d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,6 +26,9 @@ "save": "Save", "create": "Create", "cancel": "Cancel", + "accept": "Accept", + "decline": "Decline", + "welcome": "Welcome", "update": "Update", "deselect": "Deselect", "download": "Download", diff --git a/resources/scripts/admin/admin-router.js b/resources/scripts/admin/admin-router.js index ffc3825d..18ab534a 100644 --- a/resources/scripts/admin/admin-router.js +++ b/resources/scripts/admin/admin-router.js @@ -136,7 +136,16 @@ const AdminUpdateApp = () => const AdminFileDisk = () => import('@/scripts/admin/views/settings/FileDiskSetting.vue') +const NoCompanyView = () => + import('@/scripts/admin/views/NoCompanyView.vue') + export default [ + { + path: '/admin/no-company', + name: 'no.company', + component: NoCompanyView, + meta: { requiresAuth: true }, + }, { path: '/installation', component: LayoutInstallation, diff --git a/resources/scripts/admin/components/modal-components/InviteMemberModal.vue b/resources/scripts/admin/components/modal-components/InviteMemberModal.vue new file mode 100644 index 00000000..0e2ccb26 --- /dev/null +++ b/resources/scripts/admin/components/modal-components/InviteMemberModal.vue @@ -0,0 +1,132 @@ + + + diff --git a/resources/scripts/admin/stores/global.js b/resources/scripts/admin/stores/global.js index a40c5de0..1787fd59 100644 --- a/resources/scripts/admin/stores/global.js +++ b/resources/scripts/admin/stores/global.js @@ -70,6 +70,13 @@ export const useGlobalStore = (useWindow = false) => { moduleStore.apiToken = response.data.global_settings.api_token moduleStore.enableModules = response.data.modules + // invitation store + if (response.data.pending_invitations) { + const { useInvitationStore } = await import('@/scripts/admin/stores/invitation') + const invitationStore = useInvitationStore() + invitationStore.setPendingInvitations(response.data.pending_invitations) + } + // company store companyStore.companies = response.data.companies companyStore.selectedCompany = response.data.current_company diff --git a/resources/scripts/admin/stores/invitation.js b/resources/scripts/admin/stores/invitation.js new file mode 100644 index 00000000..da551120 --- /dev/null +++ b/resources/scripts/admin/stores/invitation.js @@ -0,0 +1,56 @@ +import { defineStore } from 'pinia' +import { useNotificationStore } from '@/scripts/stores/notification' +import { useGlobalStore } from '@/scripts/admin/stores/global' +import http from '@/scripts/http' + +export const useInvitationStore = defineStore('invitation', { + state: () => ({ + pendingInvitations: [], + }), + + actions: { + setPendingInvitations(invitations) { + this.pendingInvitations = invitations + }, + + async fetchPending() { + const response = await http.get('/api/v1/invitations/pending') + this.pendingInvitations = response.data.invitations + return response + }, + + async accept(token) { + const notificationStore = useNotificationStore() + const globalStore = useGlobalStore() + + const response = await http.post(`/api/v1/invitations/${token}/accept`) + + notificationStore.showNotification({ + type: 'success', + message: 'Invitation accepted!', + }) + + // Refresh bootstrap to get updated companies list + await globalStore.bootstrap() + + return response + }, + + async decline(token) { + const notificationStore = useNotificationStore() + + const response = await http.post(`/api/v1/invitations/${token}/decline`) + + this.pendingInvitations = this.pendingInvitations.filter( + (inv) => inv.token !== token + ) + + notificationStore.showNotification({ + type: 'success', + message: 'Invitation declined.', + }) + + return response + }, + }, +}) diff --git a/resources/scripts/admin/stores/members.js b/resources/scripts/admin/stores/members.js index 605b2af6..d223175a 100644 --- a/resources/scripts/admin/stores/members.js +++ b/resources/scripts/admin/stores/members.js @@ -12,6 +12,7 @@ export const useMembersStore = (useWindow = false) => { roles: [], users: [], totalUsers: 0, + pendingInvitations: [], currentUser: null, selectAllField: false, selectedUsers: [], @@ -226,6 +227,63 @@ export const useMembersStore = (useWindow = false) => { this.selectAllField = true } }, + + fetchPendingInvitations() { + return new Promise((resolve, reject) => { + http + .get('/api/v1/company-invitations') + .then((response) => { + this.pendingInvitations = response.data.invitations + resolve(response) + }) + .catch((err) => { + handleError(err) + reject(err) + }) + }) + }, + + inviteMember(data) { + return new Promise((resolve, reject) => { + http + .post('/api/v1/company-invitations', data) + .then((response) => { + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: global.t('members.invited_message'), + }) + this.fetchPendingInvitations() + resolve(response) + }) + .catch((err) => { + handleError(err) + reject(err) + }) + }) + }, + + cancelInvitation(id) { + return new Promise((resolve, reject) => { + http + .delete(`/api/v1/company-invitations/${id}`) + .then((response) => { + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: global.t('members.invitation_cancelled'), + }) + this.pendingInvitations = this.pendingInvitations.filter( + (inv) => inv.id !== id + ) + resolve(response) + }) + .catch((err) => { + handleError(err) + reject(err) + }) + }) + }, }, })() } diff --git a/resources/scripts/admin/views/NoCompanyView.vue b/resources/scripts/admin/views/NoCompanyView.vue new file mode 100644 index 00000000..77035b95 --- /dev/null +++ b/resources/scripts/admin/views/NoCompanyView.vue @@ -0,0 +1,99 @@ + + + diff --git a/resources/scripts/admin/views/members/Index.vue b/resources/scripts/admin/views/members/Index.vue index 724a9e9d..cf4b4f87 100644 --- a/resources/scripts/admin/views/members/Index.vue +++ b/resources/scripts/admin/views/members/Index.vue @@ -27,7 +27,22 @@ + + {{ $t('members.invite_member') }} + + + - {{ $t('members.add_user') }} + {{ $t('members.add_member') }} @@ -181,6 +196,47 @@ + + +
+

+ {{ $t('members.pending_invitations') }} +

+ +
+
+
+

+ {{ invitation.email }} +

+

+ {{ invitation.role?.title }} · + {{ $t('members.invited_by') }}: {{ invitation.invited_by?.name }} +

+
+ + {{ $t('members.cancel_invitation') }} + +
+
+
+
+ + @@ -194,6 +250,7 @@ import { useDialogStore } from '@/scripts/stores/dialog' import { useUserStore } from '@/scripts/admin/stores/user' import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue' import UserDropdown from '@/scripts/admin/components/dropdowns/MemberIndexDropdown.vue' +import InviteMemberModal from '@/scripts/admin/components/modal-components/InviteMemberModal.vue' import abilities from '@/scripts/admin/stub/abilities' const notificationStore = useNotificationStore() @@ -204,6 +261,7 @@ const userStore = useUserStore() const router = useRouter() let showFilters = ref(false) +let showInviteModal = ref(false) let isFetchingInitialData = ref(true) let id = ref(null) let sortedBy = ref('created_at') @@ -277,8 +335,13 @@ watch( onMounted(() => { usersStore.fetchUsers() usersStore.fetchRoles() + usersStore.fetchPendingInvitations() }) +function cancelInvitation(id) { + usersStore.cancelInvitation(id) +} + onUnmounted(() => { if (usersStore.selectAllField) { usersStore.selectAllUsers() diff --git a/resources/scripts/components/CompanySwitcher.vue b/resources/scripts/components/CompanySwitcher.vue index 78aedacc..31e50463 100644 --- a/resources/scripts/components/CompanySwitcher.vue +++ b/resources/scripts/components/CompanySwitcher.vue @@ -132,6 +132,58 @@ + +
+ +
+
+
+ + {{ initGenerator(invitation.company?.name || '?') }} + +
+ {{ invitation.company?.name }} + {{ invitation.role?.title }} +
+
+
+ + +
+
+
+
+