mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 11:14:06 +00:00
feat(modules): relocate marketplace browser to super-admin context
The module marketplace browser UI (ModuleIndexView, ModuleDetailView,
ModuleCard, the four-step installer store) was filed under
features/company/modules/ only by historical accident — it's authorized via
the manage modules ability (super-admin-only) and conceptually belongs in the
admin context, not the company context.
- Move features/company/modules/{store.ts, views/ModuleIndexView.vue,
views/ModuleDetailView.vue, components/ModuleCard.vue} to
features/admin/modules/.
- Update hardcoded /admin/modules/... paths in the moved files to
/admin/administration/modules/... so the breadcrumbs and ModuleCard
navigation target the new admin-context routes.
- Tighten the four-step installer's silent catch {} blocks in the moved
store.ts: errors were being swallowed, now they dispatch through the
global notification store instead.
- New features/admin/modules/routes.ts declares admin.modules.index +
admin.modules.view as children of /admin/administration with
meta.isSuperAdmin: true.
- features/admin/{index,routes}.ts re-export and mount the relocated routes.
- config/invoiceshelf.php gains a new AdminModules entry in admin_menu
pointing at /admin/administration/modules with super_admin_only: true.
- The dev-gated navigation.modules entry in main_menu is replaced (not
deleted) with a non-gated entry pointing at the new company-context
Active Modules index page that lands in the next commit. The
ability is set to manage modules so non-owners can't see it.
The new company-context Active Modules index, schema-driven settings page,
and dynamic sidebar group are introduced in subsequent commits.
This commit is contained in:
@@ -346,21 +346,16 @@ return [
|
|||||||
'ability' => 'view-expense',
|
'ability' => 'view-expense',
|
||||||
'model' => Expense::class,
|
'model' => Expense::class,
|
||||||
],
|
],
|
||||||
// TODO: remove env check once the module management os implemented.
|
[
|
||||||
...(
|
'title' => 'navigation.modules',
|
||||||
env('APP_ENV', 'production') == 'development' ? [
|
'group' => 3,
|
||||||
[
|
'link' => '/admin/modules',
|
||||||
'title' => 'navigation.modules',
|
'icon' => 'PuzzlePieceIcon',
|
||||||
'group' => 3,
|
'name' => 'Modules',
|
||||||
'link' => '/admin/modules',
|
'owner_only' => false,
|
||||||
'icon' => 'PuzzlePieceIcon',
|
'ability' => 'manage modules',
|
||||||
'name' => 'Modules',
|
'model' => '',
|
||||||
'owner_only' => true,
|
],
|
||||||
'ability' => '',
|
|
||||||
'model' => '',
|
|
||||||
],
|
|
||||||
] : []
|
|
||||||
),
|
|
||||||
[
|
[
|
||||||
'title' => 'navigation.members',
|
'title' => 'navigation.members',
|
||||||
'group' => 3,
|
'group' => 3,
|
||||||
@@ -430,6 +425,17 @@ return [
|
|||||||
'ability' => '',
|
'ability' => '',
|
||||||
'model' => '',
|
'model' => '',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'title' => 'navigation.modules',
|
||||||
|
'group' => 1,
|
||||||
|
'link' => '/admin/administration/modules',
|
||||||
|
'icon' => 'PuzzlePieceIcon',
|
||||||
|
'name' => 'AdminModules',
|
||||||
|
'owner_only' => false,
|
||||||
|
'super_admin_only' => true,
|
||||||
|
'ability' => '',
|
||||||
|
'model' => '',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'title' => 'navigation.settings',
|
'title' => 'navigation.settings',
|
||||||
'group' => 1,
|
'group' => 1,
|
||||||
|
|||||||
@@ -18,3 +18,19 @@ export { default as AdminSettingsView } from './views/AdminSettingsView.vue'
|
|||||||
|
|
||||||
export { default as AdminCompanyDropdown } from './components/AdminCompanyDropdown.vue'
|
export { default as AdminCompanyDropdown } from './components/AdminCompanyDropdown.vue'
|
||||||
export { default as AdminUserDropdown } from './components/AdminUserDropdown.vue'
|
export { default as AdminUserDropdown } from './components/AdminUserDropdown.vue'
|
||||||
|
|
||||||
|
// Modules (super-admin marketplace browser)
|
||||||
|
export {
|
||||||
|
adminModuleRoutes,
|
||||||
|
useModuleStore,
|
||||||
|
ModuleIndexView,
|
||||||
|
ModuleDetailView,
|
||||||
|
ModuleCard,
|
||||||
|
} from './modules'
|
||||||
|
export type {
|
||||||
|
ModuleState,
|
||||||
|
ModuleStore,
|
||||||
|
ModuleDetailResponse,
|
||||||
|
ModuleDetailMeta,
|
||||||
|
InstallationStep,
|
||||||
|
} from './modules'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative shadow-md border-2 border-line-default/60 rounded-lg cursor-pointer overflow-hidden h-100"
|
class="relative shadow-md border-2 border-line-default/60 rounded-lg cursor-pointer overflow-hidden h-100"
|
||||||
@click="$router.push(`/admin/modules/${data.slug}`)"
|
@click="$router.push(`/admin/administration/modules/${data.slug}`)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="data.purchased"
|
v-if="data.purchased"
|
||||||
17
resources/scripts/features/admin/modules/index.ts
Normal file
17
resources/scripts/features/admin/modules/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { adminModuleRoutes } from './routes'
|
||||||
|
|
||||||
|
export { useModuleStore } from './store'
|
||||||
|
export type {
|
||||||
|
ModuleState,
|
||||||
|
ModuleStore,
|
||||||
|
ModuleDetailResponse,
|
||||||
|
ModuleDetailMeta,
|
||||||
|
InstallationStep,
|
||||||
|
} from './store'
|
||||||
|
|
||||||
|
// Views
|
||||||
|
export { default as ModuleIndexView } from './views/ModuleIndexView.vue'
|
||||||
|
export { default as ModuleDetailView } from './views/ModuleDetailView.vue'
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { default as ModuleCard } from './components/ModuleCard.vue'
|
||||||
34
resources/scripts/features/admin/modules/routes.ts
Normal file
34
resources/scripts/features/admin/modules/routes.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const ModuleIndexView = () => import('./views/ModuleIndexView.vue')
|
||||||
|
const ModuleDetailView = () => import('./views/ModuleDetailView.vue')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super-admin marketplace browser routes.
|
||||||
|
*
|
||||||
|
* These are mounted as children of `/admin/administration` in features/admin/routes.ts,
|
||||||
|
* meaning they require `meta.isSuperAdmin` and the admin-mode bootstrap.
|
||||||
|
*
|
||||||
|
* Company-context module routes (the read-only Active Modules index and the
|
||||||
|
* schema-rendered settings page) live in features/company/modules/routes.ts.
|
||||||
|
*/
|
||||||
|
export const adminModuleRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: 'modules',
|
||||||
|
name: 'admin.modules.index',
|
||||||
|
component: ModuleIndexView,
|
||||||
|
meta: {
|
||||||
|
isSuperAdmin: true,
|
||||||
|
title: 'modules.title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'modules/:slug',
|
||||||
|
name: 'admin.modules.view',
|
||||||
|
component: ModuleDetailView,
|
||||||
|
meta: {
|
||||||
|
isSuperAdmin: true,
|
||||||
|
title: 'modules.title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
193
resources/scripts/features/admin/modules/store.ts
Normal file
193
resources/scripts/features/admin/modules/store.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { moduleService } from '../../../api/services/module.service'
|
||||||
|
import type {
|
||||||
|
Module,
|
||||||
|
ModuleReview,
|
||||||
|
ModuleFaq,
|
||||||
|
ModuleLink,
|
||||||
|
ModuleScreenshot,
|
||||||
|
} from '../../../types/domain/module'
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ModuleDetailMeta {
|
||||||
|
modules: Module[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleDetailResponse {
|
||||||
|
data: Module
|
||||||
|
meta: ModuleDetailMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallationStep {
|
||||||
|
translationKey: string
|
||||||
|
stepUrl: string
|
||||||
|
time: string | null
|
||||||
|
started: boolean
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Store
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ModuleState {
|
||||||
|
currentModule: ModuleDetailResponse | null
|
||||||
|
modules: Module[]
|
||||||
|
apiToken: string | null
|
||||||
|
currentUser: {
|
||||||
|
api_token: string | null
|
||||||
|
}
|
||||||
|
enableModules: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useModuleStore = defineStore('modules', {
|
||||||
|
state: (): ModuleState => ({
|
||||||
|
currentModule: null,
|
||||||
|
modules: [],
|
||||||
|
apiToken: null,
|
||||||
|
currentUser: {
|
||||||
|
api_token: null,
|
||||||
|
},
|
||||||
|
enableModules: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
salesTaxUSEnabled: (state): boolean =>
|
||||||
|
state.enableModules.includes('SalesTaxUS'),
|
||||||
|
|
||||||
|
installedModules: (state): Module[] =>
|
||||||
|
state.modules.filter((m) => m.installed),
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchModules(): Promise<void> {
|
||||||
|
const response = await moduleService.list()
|
||||||
|
this.modules = response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchModule(slug: string): Promise<ModuleDetailResponse> {
|
||||||
|
const response = await moduleService.get(slug)
|
||||||
|
const data = response as unknown as ModuleDetailResponse
|
||||||
|
|
||||||
|
if ((data as Record<string, unknown>).error === 'invalid_token') {
|
||||||
|
this.currentModule = null
|
||||||
|
this.modules = []
|
||||||
|
this.apiToken = null
|
||||||
|
this.currentUser.api_token = null
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentModule = data
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const response = await moduleService.checkToken(token)
|
||||||
|
return {
|
||||||
|
success: response.success ?? false,
|
||||||
|
error: response.error,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||||
|
return moduleService.disable(moduleName)
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||||
|
return moduleService.enable(moduleName)
|
||||||
|
},
|
||||||
|
|
||||||
|
async installModule(
|
||||||
|
moduleName: string,
|
||||||
|
version: string,
|
||||||
|
onStepUpdate?: (step: InstallationStep) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const steps: InstallationStep[] = [
|
||||||
|
{
|
||||||
|
translationKey: 'modules.download_zip_file',
|
||||||
|
stepUrl: '/api/v1/modules/download',
|
||||||
|
time: null,
|
||||||
|
started: false,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
translationKey: 'modules.unzipping_package',
|
||||||
|
stepUrl: '/api/v1/modules/unzip',
|
||||||
|
time: null,
|
||||||
|
started: false,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
translationKey: 'modules.copying_files',
|
||||||
|
stepUrl: '/api/v1/modules/copy',
|
||||||
|
time: null,
|
||||||
|
started: false,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
translationKey: 'modules.completing_installation',
|
||||||
|
stepUrl: '/api/v1/modules/complete',
|
||||||
|
time: null,
|
||||||
|
started: false,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let path: string | null = null
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
step.started = true
|
||||||
|
onStepUpdate?.(step)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stepFns: Record<string, () => Promise<Record<string, unknown>>> = {
|
||||||
|
'/api/v1/modules/download': () =>
|
||||||
|
moduleService.download({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||||
|
'/api/v1/modules/unzip': () =>
|
||||||
|
moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||||
|
'/api/v1/modules/copy': () =>
|
||||||
|
moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||||
|
'/api/v1/modules/complete': () =>
|
||||||
|
moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise<Record<string, unknown>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await stepFns[step.stepUrl]()
|
||||||
|
step.completed = true
|
||||||
|
onStepUpdate?.(step)
|
||||||
|
|
||||||
|
if ((result as Record<string, unknown>).path) {
|
||||||
|
path = (result as Record<string, unknown>).path as string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(result as Record<string, unknown>).success) {
|
||||||
|
const message = (result as Record<string, unknown>).error
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||||
|
useNotificationStore().showNotification({
|
||||||
|
type: 'error',
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
step.completed = true
|
||||||
|
onStepUpdate?.(step)
|
||||||
|
const { useNotificationStore } = await import('@/scripts/stores/notification.store')
|
||||||
|
useNotificationStore().showNotification({
|
||||||
|
type: 'error',
|
||||||
|
message: err instanceof Error ? err.message : 'Module installation failed',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ModuleStore = ReturnType<typeof useModuleStore>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<BasePageHeader :title="moduleData.name">
|
<BasePageHeader :title="moduleData.name">
|
||||||
<BaseBreadcrumb>
|
<BaseBreadcrumb>
|
||||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||||
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/modules" />
|
<BaseBreadcrumbItem :title="$t('modules.title')" to="/admin/administration/modules" />
|
||||||
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
|
<BaseBreadcrumbItem :title="moduleData.name" to="#" active />
|
||||||
</BaseBreadcrumb>
|
</BaseBreadcrumb>
|
||||||
</BasePageHeader>
|
</BasePageHeader>
|
||||||
@@ -291,7 +291,7 @@
|
|||||||
<div class="flex items-center justify-between space-x-4">
|
<div class="flex items-center justify-between space-x-4">
|
||||||
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
|
<h2 class="text-lg font-medium text-heading">{{ $t('modules.other_modules') }}</h2>
|
||||||
<a
|
<a
|
||||||
href="/admin/modules"
|
href="/admin/administration/modules"
|
||||||
class="whitespace-nowrap text-sm font-medium text-primary-600 hover:text-primary-500"
|
class="whitespace-nowrap text-sm font-medium text-primary-600 hover:text-primary-500"
|
||||||
>
|
>
|
||||||
{{ $t('modules.view_all') }}
|
{{ $t('modules.view_all') }}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import { adminModuleRoutes } from './modules/routes'
|
||||||
|
|
||||||
const CompanyLayout = () => import('../../layouts/CompanyLayout.vue')
|
const CompanyLayout = () => import('../../layouts/CompanyLayout.vue')
|
||||||
const AdminDashboardView = () => import('./views/AdminDashboardView.vue')
|
const AdminDashboardView = () => import('./views/AdminDashboardView.vue')
|
||||||
@@ -64,6 +65,7 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
isSuperAdmin: true,
|
isSuperAdmin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...adminModuleRoutes,
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'admin.settings',
|
name: 'admin.settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user