Add super-admin Administration section and restructure global vs company settings

- Add Administration sidebar section (super-admin only) with Companies, Users, and Global Settings pages
- Add super-admin middleware, controllers, and API routes under /api/v1/super-admin/
- Allow super-admins to manage all companies and users across tenants
- Add user impersonation with short-lived tokens, audit logging, and UI banner
- Move system-level settings (Mail, PDF, Backup, Update, File Disk) from per-company to Administration > Global Settings
- Convert save_pdf_to_disk from CompanySetting to global Setting
- Add per-company mail configuration overrides (optional, falls back to global)
- Add CompanyMailConfigService to apply company mail config before sending emails
This commit is contained in:
Darko Gjorgjijoski
2026-04-03 10:35:40 +02:00
parent 25986b7bd5
commit 9432da467e
40 changed files with 2324 additions and 91 deletions

View File

@@ -47,17 +47,10 @@ const ExpenseCategory = () =>
import('@/scripts/admin/views/settings/ExpenseCategorySetting.vue')
const ExchangeRateSetting = () =>
import('@/scripts/admin/views/settings/ExchangeRateProviderSetting.vue')
const MailConfig = () =>
import('@/scripts/admin/views/settings/MailConfigSetting.vue')
const FileDisk = () =>
import('@/scripts/admin/views/settings/FileDiskSetting.vue')
const Backup = () => import('@/scripts/admin/views/settings/BackupSetting.vue')
const UpdateApp = () =>
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
const RolesSettings = () =>
import('@/scripts/admin/views/settings/RolesSettings.vue')
const PDFGenerationSettings = () =>
import('@/scripts/admin/views/settings/PDFGenerationSetting.vue')
const CompanyMailConfig = () =>
import('@/scripts/admin/views/settings/CompanyMailConfigSetting.vue')
// Items
const ItemsIndex = () => import('@/scripts/admin/views/items/Index.vue')
@@ -115,6 +108,28 @@ const ModuleView = () => import('@/scripts/admin/views/modules/View.vue')
const InvoicePublicPage = () =>
import('@/scripts/components/InvoicePublicPage.vue')
// Administration (Super Admin)
const AdminCompaniesIndex = () =>
import('@/scripts/admin/views/administration/companies/Index.vue')
const AdminCompaniesEdit = () =>
import('@/scripts/admin/views/administration/companies/Edit.vue')
const AdminUsersIndex = () =>
import('@/scripts/admin/views/administration/users/Index.vue')
const AdminUsersEdit = () =>
import('@/scripts/admin/views/administration/users/Edit.vue')
const AdminSettingsIndex = () =>
import('@/scripts/admin/views/administration/settings/SettingsIndex.vue')
const AdminMailConfig = () =>
import('@/scripts/admin/views/settings/MailConfigSetting.vue')
const AdminPDFGeneration = () =>
import('@/scripts/admin/views/settings/PDFGenerationSetting.vue')
const AdminBackup = () =>
import('@/scripts/admin/views/settings/BackupSetting.vue')
const AdminUpdateApp = () =>
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
const AdminFileDisk = () =>
import('@/scripts/admin/views/settings/FileDiskSetting.vue')
export default [
{
path: '/installation',
@@ -304,36 +319,11 @@ export default [
meta: { ability: abilities.VIEW_EXPENSE },
component: ExpenseCategory,
},
{
path: 'mail-configuration',
name: 'mailconfig',
name: 'company.mailconfig',
meta: { isOwner: true },
component: MailConfig,
},
{
path: 'file-disk',
name: 'file-disk',
meta: { isOwner: true },
component: FileDisk,
},
{
path: 'backup',
name: 'backup',
meta: { isOwner: true },
component: Backup,
},
{
path: 'update-app',
name: 'updateapp',
meta: { isOwner: true },
component: UpdateApp,
},
{
path: 'pdf-generation',
name: 'pdf.generation',
meta: { isOwner: true },
component: PDFGenerationSettings,
component: CompanyMailConfig,
},
],
},
@@ -495,6 +485,65 @@ export default [
meta: { ability: abilities.VIEW_FINANCIAL_REPORT },
component: ReportsIndex,
},
// Administration (Super Admin)
{
path: 'administration/companies',
name: 'admin.companies.index',
meta: { isSuperAdmin: true },
component: AdminCompaniesIndex,
},
{
path: 'administration/companies/:id/edit',
name: 'admin.companies.edit',
meta: { isSuperAdmin: true },
component: AdminCompaniesEdit,
},
{
path: 'administration/users',
name: 'admin.users.index',
meta: { isSuperAdmin: true },
component: AdminUsersIndex,
},
{
path: 'administration/users/:id/edit',
name: 'admin.users.edit',
meta: { isSuperAdmin: true },
component: AdminUsersEdit,
},
{
path: 'administration/settings',
name: 'admin.settings',
meta: { isSuperAdmin: true },
component: AdminSettingsIndex,
children: [
{
path: 'mail-configuration',
name: 'admin.settings.mail',
component: AdminMailConfig,
},
{
path: 'pdf-generation',
name: 'admin.settings.pdf',
component: AdminPDFGeneration,
},
{
path: 'backup',
name: 'admin.settings.backup',
component: AdminBackup,
},
{
path: 'update-app',
name: 'admin.settings.update',
component: AdminUpdateApp,
},
{
path: 'file-disk',
name: 'admin.settings.filedisk',
component: AdminFileDisk,
},
],
},
],
},
{ path: '/:catchAll(.*)', component: NotFoundPage },

View File

@@ -0,0 +1,41 @@
<template>
<div
v-if="isImpersonating"
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
>
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
<span>{{ $t('administration.users.impersonating_banner') }}</span>
<button
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
:disabled="isStopping"
@click="stopImpersonating"
>
{{ $t('administration.users.stop_impersonating') }}
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import Ls from '@/scripts/services/ls.js'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
const administrationStore = useAdministrationStore()
let isStopping = ref(false)
const isImpersonating = computed(() => {
return Ls.get('admin.impersonating') === 'true'
})
async function stopImpersonating() {
isStopping.value = true
try {
await administrationStore.stopImpersonating()
} catch {
// Token already cleaned up in store action
}
window.location.href = '/admin/administration/users'
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`/admin/administration/companies/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
</BaseDropdown>
</template>
<script setup>
defineProps({
row: {
type: Object,
default: null,
},
table: {
type: Object,
default: null,
},
loadData: {
type: Function,
default: null,
},
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
</template>
<router-link :to="`/admin/administration/users/${row.id}/edit`">
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<BaseDropdownItem
v-if="row.id !== userStore.currentUser?.id"
@click="onImpersonate"
>
<BaseIcon
name="ArrowRightEndOnRectangleIcon"
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
/>
{{ $t('administration.users.impersonate') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/scripts/admin/stores/user'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { useDialogStore } from '@/scripts/stores/dialog'
const props = defineProps({
row: {
type: Object,
default: null,
},
table: {
type: Object,
default: null,
},
loadData: {
type: Function,
default: null,
},
})
const userStore = useUserStore()
const administrationStore = useAdministrationStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
function onImpersonate() {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('administration.users.impersonate_confirm', {
name: props.row.name,
}),
yesLabel: t('administration.users.impersonate'),
noLabel: t('general.cancel'),
variant: 'danger',
size: 'lg',
hideNoButton: false,
})
.then((confirmed) => {
if (confirmed) {
administrationStore.impersonateUser(props.row.id).then(() => {
window.location.href = '/admin/dashboard'
})
}
})
}
</script>

View File

@@ -96,6 +96,14 @@ import { required, email, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@/scripts/stores/modal'
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail'
const props = defineProps({
storeType: {
type: String,
default: 'global',
},
})
let isSaving = ref(false)
let formData = reactive({
@@ -106,6 +114,11 @@ let formData = reactive({
const modalStore = useModalStore()
const mailDriverStore = useMailDriverStore()
const companyMailStore = useCompanyMailStore()
const activeStore = computed(() => {
return props.storeType === 'company' ? companyMailStore : mailDriverStore
})
const { t } = useI18n()
const modalActive = computed(() => {
@@ -153,7 +166,7 @@ async function onTestMailSend() {
}
isSaving.value = true
let response = await mailDriverStore.sendTestMail(formData)
let response = await activeStore.value.sendTestMail(formData)
if (response.data) {
closeTestModal()
isSaving.value = false

View File

@@ -2,6 +2,8 @@
<div v-if="isAppLoaded" class="h-full">
<NotificationRoot />
<ImpersonationBanner />
<SiteHeader />
<SiteSidebar />
@@ -34,6 +36,7 @@ import SiteHeader from '@/scripts/admin/layouts/partials/TheSiteHeader.vue'
import SiteSidebar from '@/scripts/admin/layouts/partials/TheSiteSidebar.vue'
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
import ExchangeRateBulkUpdateModal from '@/scripts/admin/components/modal-components/ExchangeRateBulkUpdateModal.vue'
import ImpersonationBanner from '@/scripts/admin/components/ImpersonationBanner.vue'
const globalStore = useGlobalStore()
const route = useRoute()
@@ -52,6 +55,8 @@ onMounted(() => {
globalStore.bootstrap().then((res) => {
if (route.meta.ability && !userStore.hasAbilities(route.meta.ability)) {
router.push({ name: 'account.settings' })
} else if (route.meta.isSuperAdmin && !userStore.currentUser.is_super_admin) {
router.push({ name: 'dashboard' })
} else if (route.meta.isOwner && !userStore.currentUser.is_owner) {
router.push({ name: 'account.settings' })
}

View File

@@ -72,10 +72,16 @@
</div>
<nav
v-for="menu in globalStore.menuGroups"
:key="menu"
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="mt-5 space-y-1"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-4 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item.name"
@@ -126,10 +132,16 @@
"
>
<div
v-for="menu in globalStore.menuGroups"
:key="menu"
v-for="(menu, index) in globalStore.menuGroups"
:key="index"
class="p-0 m-0 mt-6 list-none"
>
<div
v-if="menu[0] && menu[0].group_label"
class="px-6 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
>
{{ $t(menu[0].group_label) }}
</div>
<router-link
v-for="item in menu"
:key="item"

View File

@@ -0,0 +1,162 @@
import http from '@/scripts/http'
import Ls from '@/scripts/services/ls.js'
import { defineStore } from 'pinia'
import { useNotificationStore } from '@/scripts/stores/notification'
import { handleError } from '@/scripts/helpers/error-handling'
export const useAdministrationStore = defineStore('administration', {
state: () => ({
companies: [],
totalCompanies: 0,
selectedCompany: null,
users: [],
totalUsers: 0,
}),
actions: {
fetchCompanies(params) {
return new Promise((resolve, reject) => {
http
.get('/api/v1/super-admin/companies', { params })
.then((response) => {
this.companies = response.data.data
this.totalCompanies = response.data.meta
? response.data.meta.total
: response.data.data.length
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchCompany(id) {
return new Promise((resolve, reject) => {
http
.get(`/api/v1/super-admin/companies/${id}`)
.then((response) => {
this.selectedCompany = response.data.data
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchUsers(params) {
return new Promise((resolve, reject) => {
http
.get('/api/v1/super-admin/users', { params })
.then((response) => {
this.users = response.data.data
this.totalUsers = response.data.meta
? response.data.meta.total
: response.data.data.length
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchUser(id) {
return new Promise((resolve, reject) => {
http
.get(`/api/v1/super-admin/users/${id}`)
.then((response) => {
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
updateUser(id, data) {
return new Promise((resolve, reject) => {
http
.put(`/api/v1/super-admin/users/${id}`, data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('administration.users.updated_message'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
impersonateUser(id) {
return new Promise((resolve, reject) => {
http
.post(`/api/v1/super-admin/users/${id}/impersonate`)
.then((response) => {
Ls.set('admin.impersonating', 'true')
Ls.set('auth.token', `Bearer ${response.data.token}`)
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
stopImpersonating() {
return new Promise((resolve, reject) => {
http
.post('/api/v1/super-admin/stop-impersonating')
.then((response) => {
Ls.remove('admin.impersonating')
Ls.remove('auth.token')
resolve(response)
})
.catch((err) => {
Ls.remove('admin.impersonating')
Ls.remove('auth.token')
handleError(err)
reject(err)
})
})
},
updateCompany(id, data) {
return new Promise((resolve, reject) => {
http
.put(`/api/v1/super-admin/companies/${id}`, data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t(
'administration.companies.updated_message'
),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
},
})

View File

@@ -0,0 +1,91 @@
import http from '@/scripts/http'
import { defineStore } from 'pinia'
import { useNotificationStore } from '@/scripts/stores/notification'
import { handleError } from '@/scripts/helpers/error-handling'
export const useCompanyMailStore = defineStore('company-mail', {
state: () => ({
mailConfigData: null,
mail_driver: '',
mail_drivers: [],
}),
actions: {
fetchMailDrivers() {
return new Promise((resolve, reject) => {
http
.get('/api/v1/mail/drivers')
.then((response) => {
if (response.data) {
this.mail_drivers = response.data
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
fetchMailConfig() {
return new Promise((resolve, reject) => {
http
.get('/api/v1/company/mail/company-config')
.then((response) => {
if (response.data) {
this.mailConfigData = response.data
this.mail_driver = response.data.mail_driver || ''
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
updateMailConfig(data) {
return new Promise((resolve, reject) => {
http
.post('/api/v1/company/mail/company-config', data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
notificationStore.showNotification({
type: 'success',
message: global.t('settings.mail.company_mail_config_updated'),
})
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
sendTestMail(data) {
return new Promise((resolve, reject) => {
http
.post('/api/v1/company/mail/company-test', data)
.then((response) => {
const { global } = window.i18n
const notificationStore = useNotificationStore()
if (response.data.success) {
notificationStore.showNotification({
type: 'success',
message: global.t('general.send_mail_successfully'),
})
}
resolve(response)
})
.catch((err) => {
handleError(err)
reject(err)
})
})
},
},
})

View File

@@ -0,0 +1,255 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.companies.edit_company')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="/admin/administration/companies"
/>
<BaseBreadcrumbItem
:title="$t('administration.companies.edit_company')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('administration.companies.owner')"
:error="v$.owner_id.$error && v$.owner_id.$errors[0].$message"
required
>
<BaseMultiselect
v-model="formData.owner_id"
label="name"
:invalid="v$.owner_id.$error"
:options="searchUsers"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
:filter-results="false"
:min-chars="0"
:resolve-on-load="true"
:delay="300"
object
track-by="name"
@select="onOwnerSelect"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
<BaseInput v-model="formData.tax_id" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
<BaseInput v-model="formData.vat_id" type="text" />
</BaseInputGroup>
</BaseInputGrid>
<BaseDivider class="my-6" />
<h3 class="text-lg font-medium text-gray-900 mb-4">
{{ $t('administration.companies.address') }}
</h3>
<BaseInputGrid>
<BaseInputGroup :label="$t('settings.company_info.phone')">
<BaseInput v-model="formData.address.phone" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.country')">
<BaseMultiselect
v-model="formData.address.country_id"
label="name"
:options="globalStore.countries"
value-prop="id"
:can-deselect="true"
:can-clear="false"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.state')">
<BaseInput v-model="formData.address.state" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.city')">
<BaseInput v-model="formData.address.city" type="text" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.zip')">
<BaseInput v-model="formData.address.zip" />
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.company_info.address')">
<BaseTextarea
v-model="formData.address.address_street_1"
rows="2"
/>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { required, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
import http from '@/scripts/http'
const route = useRoute()
const router = useRouter()
const globalStore = useGlobalStore()
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let isLoading = ref(true)
let isSaving = ref(false)
const formData = reactive({
name: '',
owner_id: null,
vat_id: '',
tax_id: '',
address: {
address_street_1: '',
address_street_2: '',
country_id: null,
state: '',
city: '',
phone: '',
zip: '',
},
})
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
owner_id: {
required: helpers.withMessage(t('validation.required'), required),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
await globalStore.fetchCountries()
const response = await administrationStore.fetchCompany(route.params.id)
const company = response.data.data
formData.name = company.name
formData.vat_id = company.vat_id || ''
formData.tax_id = company.tax_id || ''
if (company.owner) {
formData.owner_id = {
id: company.owner.id,
name: company.owner.name,
email: company.owner.email,
}
}
if (company.address) {
formData.address.address_street_1 = company.address.address_street_1 || ''
formData.address.address_street_2 = company.address.address_street_2 || ''
formData.address.country_id = company.address.country_id
formData.address.state = company.address.state || ''
formData.address.city = company.address.city || ''
formData.address.phone = company.address.phone || ''
formData.address.zip = company.address.zip || ''
}
isLoading.value = false
})
async function searchUsers(query) {
const response = await http.get('/api/v1/search', {
params: {
search: query,
type: 'User',
},
})
return response.data.users.map((user) => ({
id: user.id,
name: `${user.name} (${user.email})`,
email: user.email,
}))
}
function onOwnerSelect(option) {
formData.owner_id = option
}
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data = {
name: formData.name,
owner_id: formData.owner_id?.id || formData.owner_id,
vat_id: formData.vat_id,
tax_id: formData.tax_id,
address: formData.address,
}
await administrationStore.updateCompany(route.params.id, data)
isSaving.value = false
router.push({ name: 'admin.companies.index' })
}
</script>

View File

@@ -0,0 +1,188 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.companies.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.companies.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('administration.companies.company_name')"
class="flex-1 mt-2"
>
<BaseInput
v-model="filters.search"
type="text"
name="search"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.companies.no_companies')"
:description="$t('administration.companies.list_description')"
>
<BaseIcon name="BuildingOfficeIcon" class="mt-5 mb-4 h-16 w-16 text-gray-300" />
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="companyTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.companies.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-owner="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.name }}</span>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-owner_email="{ row }">
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-actions="{ row }">
<AdminCompanyDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import AdminCompanyDropdown from '@/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue'
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
let table = ref(null)
let filters = reactive({
search: '',
})
const companyTableColumns = computed(() => {
return [
{
key: 'name',
label: t('administration.companies.company_name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'owner',
label: t('administration.companies.owner'),
sortable: false,
},
{
key: 'owner_email',
label: t('general.email'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const showEmptyScreen = computed(() => {
return !administrationStore.totalCompanies && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
search: filters.search || '',
orderByField: sort.fieldName || 'name',
orderBy: sort.order || 'asc',
page,
}
isFetchingInitialData.value = true
let response = await administrationStore.fetchCompanies(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function clearFilter() {
filters.search = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.settings.title')" class="mb-6">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.settings.title')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<div class="w-full mb-6 select-wrapper xl:hidden">
<BaseMultiselect
v-model="currentSetting"
:options="menuItems"
:can-deselect="false"
value-prop="title"
track-by="title"
label="title"
object
@update:modelValue="navigateToSetting"
/>
</div>
<div class="flex">
<div class="hidden mt-1 xl:block min-w-[240px]">
<BaseList>
<BaseListItem
v-for="(menuItem, index) in menuItems"
:key="index"
:title="menuItem.title"
:to="menuItem.link"
:active="hasActiveUrl(menuItem.link)"
:index="index"
class="py-3"
>
<template #icon>
<BaseIcon :name="menuItem.icon" />
</template>
</BaseListItem>
</BaseList>
</div>
<div class="w-full overflow-hidden">
<RouterView />
</div>
</div>
</BasePage>
</template>
<script setup>
import { ref, computed, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import BaseList from '@/scripts/components/list/BaseList.vue'
import BaseListItem from '@/scripts/components/list/BaseListItem.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
let currentSetting = ref({})
const menuItems = computed(() => [
{
title: t('settings.mail.mail_config'),
link: '/admin/administration/settings/mail-configuration',
icon: 'EnvelopeIcon',
},
{
title: t('settings.menu_title.pdf_generation'),
link: '/admin/administration/settings/pdf-generation',
icon: 'DocumentIcon',
},
{
title: t('settings.menu_title.backup'),
link: '/admin/administration/settings/backup',
icon: 'CircleStackIcon',
},
{
title: t('settings.menu_title.update_app'),
link: '/admin/administration/settings/update-app',
icon: 'ArrowPathIcon',
},
{
title: t('settings.menu_title.file_disk'),
link: '/admin/administration/settings/file-disk',
icon: 'FolderIcon',
},
])
watchEffect(() => {
if (route.path === '/admin/administration/settings') {
router.push('/admin/administration/settings/mail-configuration')
}
const item = menuItems.value.find((item) => {
return route.path.indexOf(item.link) > -1
})
currentSetting.value = item
})
function hasActiveUrl(url) {
return route.path.indexOf(url) > -1
}
function navigateToSetting(setting) {
return router.push(setting.link)
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<BasePage v-if="!isLoading">
<BasePageHeader :title="$t('administration.users.edit_user')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="/admin/administration/users"
/>
<BaseBreadcrumbItem
:title="$t('administration.users.edit_user')"
to="#"
active
/>
</BaseBreadcrumb>
</BasePageHeader>
<form @submit.prevent="submitForm">
<BaseCard class="mt-6">
<BaseInputGrid class="mt-5">
<BaseInputGroup
:label="$t('users.name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="formData.name"
:invalid="v$.name.$error"
@blur="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.email')"
:error="v$.email.$error && v$.email.$errors[0].$message"
required
>
<BaseInput
v-model="formData.email"
type="email"
:invalid="v$.email.$error"
@blur="v$.email.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')">
<BaseInput v-model="formData.phone" type="text" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('users.password')"
:error="v$.password.$error && v$.password.$errors[0].$message"
>
<BaseInput
v-model="formData.password"
type="password"
:invalid="v$.password.$error"
autocomplete="new-password"
@blur="v$.password.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div v-if="userData" class="mt-6 text-sm text-gray-500">
<p>
<strong>{{ $t('administration.users.role') }}:</strong>
{{ userData.role }}
</p>
<p v-if="userData.companies && userData.companies.length" class="mt-1">
<strong>{{ $t('navigation.companies') }}:</strong>
{{ userData.companies.map((c) => c.name).join(', ') }}
</p>
</div>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
type="submit"
class="mt-6"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
</BaseCard>
</form>
</BasePage>
<BaseGlobalLoader v-else />
</template>
<script setup>
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import { required, email, minLength, helpers } from '@vuelidate/validators'
import { useVuelidate } from '@vuelidate/core'
const route = useRoute()
const router = useRouter()
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let isLoading = ref(true)
let isSaving = ref(false)
let userData = ref(null)
const formData = reactive({
name: '',
email: '',
phone: '',
password: '',
})
const rules = computed(() => {
return {
name: {
required: helpers.withMessage(t('validation.required'), required),
},
email: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
password: {
minLength: helpers.withMessage(
t('validation.password_min_length', { count: 8 }),
minLength(8)
),
},
}
})
const v$ = useVuelidate(
rules,
computed(() => formData)
)
onMounted(async () => {
const response = await administrationStore.fetchUser(route.params.id)
const user = response.data.data
userData.value = user
formData.name = user.name
formData.email = user.email
formData.phone = user.phone || ''
isLoading.value = false
})
async function submitForm() {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const data = {
name: formData.name,
email: formData.email,
phone: formData.phone,
}
if (formData.password) {
data.password = formData.password
}
await administrationStore.updateUser(route.params.id, data)
isSaving.value = false
router.push({ name: 'admin.users.index' })
}
</script>

View File

@@ -0,0 +1,252 @@
<template>
<BasePage>
<BasePageHeader :title="$t('administration.users.title')">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem
:title="$t('administration.users.title')"
to="#"
active
/>
</BaseBreadcrumb>
<template #actions>
<div class="flex items-center justify-end space-x-5">
<BaseButton variant="primary-outline" @click="toggleFilter">
{{ $t('general.filter') }}
<template #right="slotProps">
<BaseIcon
v-if="!showFilters"
name="FunnelIcon"
:class="slotProps.class"
/>
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
</template>
</BaseButton>
</div>
</template>
</BasePageHeader>
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
<BaseInputGroup
:label="$t('users.name')"
class="flex-1 mt-2 mr-4"
>
<BaseInput
v-model="filters.display_name"
type="text"
name="name"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('general.email')" class="flex-1 mt-2 mr-4">
<BaseInput
v-model="filters.email"
type="text"
name="email"
autocomplete="off"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('users.phone')" class="flex-1 mt-2">
<BaseInput
v-model="filters.phone"
type="text"
name="phone"
autocomplete="off"
/>
</BaseInputGroup>
</BaseFilterWrapper>
<BaseEmptyPlaceholder
v-show="showEmptyScreen"
:title="$t('administration.users.no_users')"
:description="$t('administration.users.list_description')"
>
<BaseIcon
name="UsersIcon"
class="mt-5 mb-4 h-16 w-16 text-gray-300"
/>
</BaseEmptyPlaceholder>
<div v-show="!showEmptyScreen" class="relative table-container">
<BaseTable
ref="table"
:data="fetchData"
:columns="userTableColumns"
class="mt-3"
>
<template #cell-name="{ row }">
<router-link
:to="{
name: 'admin.users.edit',
params: { id: row.data.id },
}"
class="font-medium text-primary-500"
>
{{ row.data.name }}
</router-link>
</template>
<template #cell-email="{ row }">
<span>{{ row.data.email }}</span>
</template>
<template #cell-role="{ row }">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="getRoleBadgeClass(row.data.role)"
>
{{ row.data.role }}
</span>
</template>
<template #cell-companies="{ row }">
<div v-if="row.data.companies && row.data.companies.length" class="flex flex-wrap gap-1">
<span
v-for="company in row.data.companies.slice(0, 3)"
:key="company.id"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700"
>
{{ company.name }}
</span>
<span
v-if="row.data.companies.length > 3"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500"
>
+{{ row.data.companies.length - 3 }}
</span>
</div>
<span v-else class="text-gray-400">-</span>
</template>
<template #cell-actions="{ row }">
<AdminUserDropdown
:row="row.data"
:table="table"
:load-data="refreshTable"
/>
</template>
</BaseTable>
</div>
</BasePage>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
import AdminUserDropdown from '@/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue'
const administrationStore = useAdministrationStore()
const { t } = useI18n()
let showFilters = ref(false)
let isFetchingInitialData = ref(true)
let table = ref(null)
let filters = reactive({
display_name: '',
email: '',
phone: '',
})
const userTableColumns = computed(() => {
return [
{
key: 'name',
label: t('users.name'),
thClass: 'extra',
tdClass: 'font-medium text-gray-900',
},
{
key: 'email',
label: t('general.email'),
},
{
key: 'role',
label: t('administration.users.role'),
sortable: false,
},
{
key: 'companies',
label: t('navigation.companies'),
sortable: false,
},
{
key: 'actions',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
]
})
const showEmptyScreen = computed(() => {
return !administrationStore.totalUsers && !isFetchingInitialData.value
})
watch(
filters,
() => {
refreshTable()
},
{ deep: true }
)
function refreshTable() {
table.value && table.value.refresh()
}
async function fetchData({ page, filter, sort }) {
let data = {
display_name: filters.display_name || '',
email: filters.email || '',
phone: filters.phone || '',
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
isFetchingInitialData.value = true
let response = await administrationStore.fetchUsers(data)
isFetchingInitialData.value = false
return {
data: response.data.data,
pagination: {
totalPages: response.data.meta.last_page,
currentPage: page,
totalCount: response.data.meta.total,
limit: 10,
},
}
}
function getRoleBadgeClass(role) {
switch (role) {
case 'super admin':
return 'bg-purple-100 text-purple-800'
case 'admin':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
function clearFilter() {
filters.display_name = ''
filters.email = ''
filters.phone = ''
}
function toggleFilter() {
if (showFilters.value) {
clearFilter()
}
showFilters.value = !showFilters.value
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<MailTestModal :store-type="'company'" />
<BaseSettingCard
:title="$t('settings.mail.company_mail_config')"
:description="$t('settings.mail.company_mail_config_desc')"
>
<div class="mt-8">
<BaseSwitchSection
v-model="useCustomMailConfig"
:title="$t('settings.mail.use_custom_mail_config')"
:description="$t('settings.mail.use_custom_mail_config_desc')"
/>
</div>
<div v-if="useCustomMailConfig && companyMailStore.mailConfigData" class="mt-8">
<component
:is="mailDriver"
:config-data="companyMailStore.mailConfigData"
:is-saving="isSaving"
:mail-drivers="companyMailStore.mail_drivers"
:is-fetching-initial-data="isFetchingInitialData"
@on-change-driver="(val) => changeDriver(val)"
@submit-data="saveEmailConfig"
>
<BaseButton
variant="primary-outline"
type="button"
class="ml-2"
:content-loading="isFetchingInitialData"
@click="openMailTestModal"
>
{{ $t('general.test_mail_conf') }}
</BaseButton>
</component>
</div>
<div v-if="!useCustomMailConfig" class="mt-4 p-4 rounded-md bg-gray-50 text-sm text-gray-500">
{{ $t('settings.mail.using_global_mail_config') }}
</div>
</BaseSettingCard>
</template>
<script setup>
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
import { ref, computed, watch } from 'vue'
import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail'
import { useModalStore } from '@/scripts/stores/modal'
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
import { useI18n } from 'vue-i18n'
let isSaving = ref(false)
let isFetchingInitialData = ref(false)
const companyMailStore = useCompanyMailStore()
const modalStore = useModalStore()
const { t } = useI18n()
const useCustomMailConfig = ref(false)
loadData()
async function loadData() {
isFetchingInitialData.value = true
await companyMailStore.fetchMailDrivers()
await companyMailStore.fetchMailConfig()
useCustomMailConfig.value =
companyMailStore.mailConfigData?.use_custom_mail_config === 'YES'
isFetchingInitialData.value = false
}
function changeDriver(value) {
companyMailStore.mail_driver = value
companyMailStore.mailConfigData.mail_driver = value
}
const mailDriver = computed(() => {
if (companyMailStore.mail_driver === 'smtp') return Smtp
if (companyMailStore.mail_driver === 'mailgun') return Mailgun
if (companyMailStore.mail_driver === 'sendmail') return Basic
if (companyMailStore.mail_driver === 'ses') return Ses
if (companyMailStore.mail_driver === 'mail') return Basic
return Smtp
})
watch(useCustomMailConfig, async (newVal, oldVal) => {
if (oldVal === undefined) return
if (!newVal) {
isSaving.value = true
await companyMailStore.updateMailConfig({
use_custom_mail_config: 'NO',
mail_driver: '',
})
isSaving.value = false
}
})
async function saveEmailConfig(value) {
try {
isSaving.value = true
await companyMailStore.updateMailConfig({
...value,
use_custom_mail_config: 'YES',
})
isSaving.value = false
} catch (e) {
isSaving.value = false
}
}
function openMailTestModal() {
modalStore.openModal({
title: t('general.test_mail_conf'),
componentName: 'MailTestModal',
size: 'sm',
})
}
</script>

View File

@@ -84,7 +84,7 @@
<script setup>
import { useDiskStore } from '@/scripts/admin/stores/disk'
import { useCompanyStore } from '@/scripts/admin/stores/company'
import { useGlobalStore } from '@/scripts/admin/stores/global'
import { useDialogStore } from '@/scripts/stores/dialog'
import { useModalStore } from '@/scripts/stores/modal'
import { ref, computed, reactive, onMounted, inject } from 'vue'
@@ -95,7 +95,7 @@ const utils = inject('utils')
const modalStore = useModalStore()
const diskStore = useDiskStore()
const companyStore = useCompanyStore()
const globalStore = useGlobalStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -139,7 +139,7 @@ const fileDiskColumns = computed(() => {
]
})
const savePdfToDisk = ref(companyStore.selectedCompanySettings.save_pdf_to_disk)
const savePdfToDisk = ref(globalStore.globalSettings?.save_pdf_to_disk || 'NO')
const savePdfToDiskField = computed({
get: () => {
@@ -156,7 +156,7 @@ const savePdfToDiskField = computed({
savePdfToDisk.value = value
await companyStore.updateCompanySettings({
await globalStore.updateGlobalSettings({
data,
message: 'general.setting_updated',
})

View File

@@ -27,6 +27,10 @@ router.beforeEach((to) => {
if (!userStore.hasAbilities(ability)) {
return { name: 'account.settings' }
}
} else if (to.meta.isSuperAdmin && isAppLoaded) {
if (!userStore.currentUser.is_super_admin) {
return { name: 'dashboard' }
}
} else if (to.meta.isOwner && isAppLoaded) {
if (!userStore.currentUser.is_owner) {
return { name: 'dashboard' }