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
This commit is contained in:
Darko Gjorgjijoski
2026-04-03 23:20:41 +02:00
parent 8a6c085288
commit 6343b4a17f
9 changed files with 491 additions and 2 deletions

View File

@@ -26,6 +26,9 @@
"save": "Save",
"create": "Create",
"cancel": "Cancel",
"accept": "Accept",
"decline": "Decline",
"welcome": "Welcome",
"update": "Update",
"deselect": "Deselect",
"download": "Download",

View File

@@ -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,

View File

@@ -0,0 +1,132 @@
<template>
<BaseModal :show="show" @close="$emit('close')">
<template #header>
<div class="flex justify-between w-full">
{{ $t('members.invite_member') }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-gray-500 cursor-pointer"
@click="$emit('close')"
/>
</div>
</template>
<form @submit.prevent="submitInvitation">
<div class="p-4 space-y-4">
<BaseInputGroup
:label="$t('members.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="form.email"
type="email"
:invalid="v$.email.$error"
@input="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('members.role')"
:error="v$.role_id.$error && v$.role_id.$errors[0].$message"
required
>
<BaseMultiselect
v-model="form.role_id"
:options="roles"
label="title"
value-prop="id"
track-by="title"
:searchable="true"
/>
</BaseInputGroup>
</div>
<div class="flex justify-end p-4 border-t border-gray-200">
<BaseButton
variant="primary-outline"
class="mr-3"
@click="$emit('close')"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSending"
:disabled="isSending"
type="submit"
>
{{ $t('members.invite_member') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useMembersStore } from '@/scripts/admin/stores/members'
import { useI18n } from 'vue-i18n'
import { helpers, required, email } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import http from '@/scripts/http'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const membersStore = useMembersStore()
const { t } = useI18n()
const isSending = ref(false)
const roles = ref([])
const form = reactive({
email: '',
role_id: null,
})
const rules = computed(() => ({
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
role_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(
rules,
computed(() => form)
)
onMounted(async () => {
const response = await http.get('/api/v1/roles')
roles.value = response.data.data
})
async function submitInvitation() {
v$.value.$touch()
if (v$.value.$invalid) return
isSending.value = true
try {
await membersStore.inviteMember({
email: form.email,
role_id: form.role_id,
})
form.email = ''
form.role_id = null
v$.value.$reset()
emit('close')
} catch (e) {
// Error handled by store
} finally {
isSending.value = false
}
}
</script>

View File

@@ -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

View File

@@ -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
},
},
})

View File

@@ -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)
})
})
},
},
})()
}

View File

@@ -0,0 +1,99 @@
<template>
<div class="flex items-center justify-center min-h-screen bg-gray-50">
<div class="w-full max-w-lg p-8">
<div class="text-center mb-8">
<BaseIcon
name="BuildingOfficeIcon"
class="w-16 h-16 mx-auto text-gray-400 mb-4"
/>
<h1 class="text-2xl font-semibold text-gray-900">
{{ $t('general.welcome') }}
</h1>
</div>
<!-- Pending Invitations -->
<div v-if="invitationStore.pendingInvitations.length > 0">
<h2 class="text-lg font-medium text-gray-700 mb-4 text-center">
{{ $t('members.pending_invitations') }}
</h2>
<div class="space-y-3">
<BaseCard
v-for="invitation in invitationStore.pendingInvitations"
:key="invitation.id"
class="p-4"
>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-gray-900">
{{ invitation.company?.name }}
</p>
<p class="text-sm text-gray-500">
{{ invitation.role?.title }} &middot;
{{ $t('members.invited_by') }}: {{ invitation.invited_by?.name }}
</p>
</div>
<div class="flex space-x-2">
<BaseButton
size="sm"
@click="acceptInvitation(invitation.token)"
>
{{ $t('general.accept') }}
</BaseButton>
<BaseButton
variant="danger"
size="sm"
@click="declineInvitation(invitation.token)"
>
{{ $t('general.decline') }}
</BaseButton>
</div>
</div>
</BaseCard>
</div>
</div>
<!-- No Invitations -->
<div v-else class="text-center">
<p class="text-gray-500">
You don't belong to any company yet. Ask your administrator to invite you.
</p>
</div>
<!-- Logout -->
<div class="mt-8 text-center">
<BaseButton variant="primary-outline" @click="logout">
{{ $t('navigation.logout') }}
</BaseButton>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInvitationStore } from '@/scripts/admin/stores/invitation'
import { useAuthStore } from '@/scripts/admin/stores/auth'
const invitationStore = useInvitationStore()
const authStore = useAuthStore()
const router = useRouter()
onMounted(async () => {
await invitationStore.fetchPending()
})
async function acceptInvitation(token) {
await invitationStore.accept(token)
router.push('/admin/dashboard')
}
async function declineInvitation(token) {
await invitationStore.decline(token)
}
async function logout() {
await authStore.logout()
router.push('/login')
}
</script>

View File

@@ -27,7 +27,22 @@
<BaseButton
v-if="userStore.currentUser.is_owner"
@click="$router.push('users/create')"
variant="primary-outline"
@click="showInviteModal = true"
>
<template #left="slotProps">
<BaseIcon
name="EnvelopeIcon"
:class="slotProps.class"
aria-hidden="true"
/>
</template>
{{ $t('members.invite_member') }}
</BaseButton>
<BaseButton
v-if="userStore.currentUser.is_owner"
@click="$router.push('members/create')"
>
<template #left="slotProps">
<BaseIcon
@@ -36,7 +51,7 @@
aria-hidden="true"
/>
</template>
{{ $t('members.add_user') }}
{{ $t('members.add_member') }}
</BaseButton>
</div>
</template>
@@ -181,6 +196,47 @@
</template>
</BaseTable>
</div>
<!-- Pending Invitations Section -->
<div
v-if="userStore.currentUser.is_owner && usersStore.pendingInvitations.length > 0"
class="mt-8"
>
<h3 class="text-lg font-medium text-gray-900 mb-4">
{{ $t('members.pending_invitations') }}
</h3>
<BaseCard>
<div class="divide-y divide-gray-200">
<div
v-for="invitation in usersStore.pendingInvitations"
:key="invitation.id"
class="flex items-center justify-between px-6 py-4"
>
<div>
<p class="text-sm font-medium text-gray-900">
{{ invitation.email }}
</p>
<p class="text-sm text-gray-500">
{{ invitation.role?.title }} &middot;
{{ $t('members.invited_by') }}: {{ invitation.invited_by?.name }}
</p>
</div>
<BaseButton
variant="danger"
size="sm"
@click="cancelInvitation(invitation.id)"
>
{{ $t('members.cancel_invitation') }}
</BaseButton>
</div>
</div>
</BaseCard>
</div>
<InviteMemberModal
:show="showInviteModal"
@close="showInviteModal = false"
/>
</BasePage>
</template>
@@ -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()

View File

@@ -132,6 +132,58 @@
</div>
</div>
</div>
<!-- Pending Invitations -->
<div
v-if="invitationStore.pendingInvitations.length > 0"
class="border-t border-gray-100 p-2"
>
<label
class="
block px-1 pt-1 pb-2 text-xs font-semibold
leading-tight text-gray-400 uppercase
"
>
{{ $t('members.pending_invitations') }}
</label>
<div
v-for="invitation in invitationStore.pendingInvitations"
:key="invitation.id"
class="p-2 px-3 rounded-md opacity-60"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
<span
class="
flex items-center justify-center mr-3
overflow-hidden text-xs font-semibold
bg-gray-200 rounded-md w-9 h-9 text-gray-400
"
>
{{ initGenerator(invitation.company?.name || '?') }}
</span>
<div class="flex flex-col">
<span class="text-sm text-gray-500">{{ invitation.company?.name }}</span>
<span class="text-xs text-gray-400">{{ invitation.role?.title }}</span>
</div>
</div>
<div class="flex space-x-1">
<button
class="text-xs px-2 py-1 rounded bg-primary-500 text-white hover:bg-primary-600"
@click.stop="acceptInvitation(invitation.token)"
>
{{ $t('general.accept') }}
</button>
<button
class="text-xs px-2 py-1 rounded bg-gray-200 text-gray-600 hover:bg-gray-300"
@click.stop="declineInvitation(invitation.token)"
>
{{ $t('general.decline') }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="userStore.currentUser.is_owner"
class="
@@ -167,6 +219,7 @@ import { useModalStore } from '../stores/modal'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin//stores/global'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useInvitationStore } from '@/scripts/admin/stores/invitation'
import CompanyModal from '@/scripts/admin/components/modal-components/CompanyModal.vue'
import abilities from '@/scripts/admin/stub/abilities'
@@ -178,6 +231,7 @@ const router = useRouter()
const globalStore = useGlobalStore()
const { t } = useI18n()
const userStore = useUserStore()
const invitationStore = useInvitationStore()
const isShow = ref(false)
const name = ref('')
const companySwitchBar = ref(null)
@@ -213,4 +267,12 @@ async function changeCompany(company) {
await globalStore.setIsAppLoaded(false)
await globalStore.bootstrap()
}
async function acceptInvitation(token) {
await invitationStore.accept(token)
}
async function declineInvitation(token) {
await invitationStore.decline(token)
}
</script>