mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-02 05:10:59 +00:00
feat: add secure module marketplace runtime (#745)
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { moduleService } from '../../../api/services/module.service'
|
||||
import type { Module } from '../../../types/domain/module'
|
||||
import type {
|
||||
Module,
|
||||
} from '../../../types/domain/module'
|
||||
import type {
|
||||
ModuleCheckResponse,
|
||||
MarketplacePairingCode,
|
||||
MarketplacePairingStatus,
|
||||
ModuleDetailResponse,
|
||||
ModuleInstallPayload,
|
||||
} from '../../../api/services/module.service'
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Types
|
||||
// ----------------------------------------------------------------
|
||||
export type { ModuleDetailResponse, ModuleDetailMeta } from '../../../api/services/module.service'
|
||||
|
||||
export interface InstallationStep {
|
||||
translationKey: string
|
||||
@@ -21,22 +18,10 @@ export interface InstallationStep {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Store
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
export interface ModuleState {
|
||||
currentModule: ModuleDetailResponse | null
|
||||
modules: Module[]
|
||||
apiToken: string | null
|
||||
currentUser: {
|
||||
api_token: string | null
|
||||
}
|
||||
marketplaceStatus: {
|
||||
authenticated: boolean
|
||||
premium: boolean
|
||||
invalidToken: boolean
|
||||
}
|
||||
marketplacePairing: MarketplacePairingStatus | null
|
||||
enableModules: string[]
|
||||
}
|
||||
|
||||
@@ -44,24 +29,13 @@ export const useModuleStore = defineStore('modules', {
|
||||
state: (): ModuleState => ({
|
||||
currentModule: null,
|
||||
modules: [],
|
||||
apiToken: null,
|
||||
currentUser: {
|
||||
api_token: null,
|
||||
},
|
||||
marketplaceStatus: {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
},
|
||||
marketplacePairing: null,
|
||||
enableModules: [],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
salesTaxUSEnabled: (state): boolean =>
|
||||
state.enableModules.includes('SalesTaxUS'),
|
||||
|
||||
installedModules: (state): Module[] =>
|
||||
state.modules.filter((m) => m.installed),
|
||||
salesTaxUSEnabled: (state): boolean => state.enableModules.includes('SalesTaxUS'),
|
||||
installedModules: (state): Module[] => state.modules.filter((m) => m.installed),
|
||||
},
|
||||
|
||||
actions: {
|
||||
@@ -76,27 +50,25 @@ export const useModuleStore = defineStore('modules', {
|
||||
return response
|
||||
},
|
||||
|
||||
async checkApiToken(token: string): Promise<ModuleCheckResponse> {
|
||||
const response = await moduleService.checkToken(token)
|
||||
this.marketplaceStatus = {
|
||||
authenticated: response.authenticated ?? false,
|
||||
premium: response.premium ?? false,
|
||||
invalidToken: response.error === 'invalid_token',
|
||||
}
|
||||
async fetchMarketplacePairing(): Promise<MarketplacePairingStatus> {
|
||||
const response = await moduleService.pairingStatus()
|
||||
this.marketplacePairing = response
|
||||
return response
|
||||
},
|
||||
|
||||
setApiToken(token: string | null): void {
|
||||
this.apiToken = token
|
||||
this.currentUser.api_token = token
|
||||
async startMarketplacePairing(): Promise<MarketplacePairingCode> {
|
||||
return moduleService.startPairing()
|
||||
},
|
||||
|
||||
clearMarketplaceStatus(): void {
|
||||
this.marketplaceStatus = {
|
||||
authenticated: false,
|
||||
premium: false,
|
||||
invalidToken: false,
|
||||
}
|
||||
async pollMarketplacePairing(): Promise<{ status: 'pending' | 'paired' }> {
|
||||
const response = await moduleService.pollPairing()
|
||||
if (response.status === 'paired') await this.fetchMarketplacePairing()
|
||||
return response
|
||||
},
|
||||
|
||||
async disconnectMarketplace(): Promise<void> {
|
||||
await moduleService.disconnectMarketplace()
|
||||
this.marketplacePairing = { paired: false, expired: false, paired_at: null }
|
||||
},
|
||||
|
||||
async disableModule(moduleName: string): Promise<{ success: boolean }> {
|
||||
@@ -111,99 +83,30 @@ export const useModuleStore = defineStore('modules', {
|
||||
payload: ModuleInstallPayload,
|
||||
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({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/unzip': () =>
|
||||
moduleService.unzip({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/copy': () =>
|
||||
moduleService.copy({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
'/api/v1/modules/complete': () =>
|
||||
moduleService.complete({
|
||||
...payload,
|
||||
path: path ?? undefined,
|
||||
}) 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
|
||||
}
|
||||
const step: InstallationStep = {
|
||||
translationKey: 'modules.completing_installation',
|
||||
stepUrl: '/api/v1/modules/install',
|
||||
time: null,
|
||||
started: true,
|
||||
completed: false,
|
||||
}
|
||||
onStepUpdate?.(step)
|
||||
|
||||
return true
|
||||
try {
|
||||
const response = await moduleService.install(payload)
|
||||
step.completed = true
|
||||
onStepUpdate?.(step)
|
||||
return response.success
|
||||
} 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
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="rounded-xl border border-line-default bg-surface-secondary p-6">
|
||||
<!-- Not purchased -->
|
||||
<template v-if="!moduleData.purchased">
|
||||
<a :href="buyLink" target="_blank">
|
||||
<a :href="buyLink" target="_blank" rel="noopener">
|
||||
<BaseButton size="lg" class="w-full flex items-center justify-center">
|
||||
<BaseIcon name="ShoppingCartIcon" class="mr-2" />
|
||||
{{ $t('modules.buy_now') }}
|
||||
@@ -413,7 +413,7 @@ const displayImages = computed<Array<{ url: string }>>(() => {
|
||||
})
|
||||
|
||||
const buyLink = computed<string>(() => {
|
||||
return `/modules/${moduleData.value?.slug ?? ''}`
|
||||
return moduleData.value?.purchase_url ?? '#'
|
||||
})
|
||||
|
||||
watch(() => route.params.slug, () => {
|
||||
@@ -440,7 +440,7 @@ async function loadData(): Promise<void> {
|
||||
}
|
||||
|
||||
async function handleInstall(): Promise<void> {
|
||||
if (!moduleData.value) return
|
||||
if (!moduleData.value?.latest_module_version) return
|
||||
|
||||
installationSteps.length = 0
|
||||
isInstalling.value = true
|
||||
@@ -448,9 +448,7 @@ async function handleInstall(): Promise<void> {
|
||||
const success = await moduleStore.installModule(
|
||||
{
|
||||
slug: moduleData.value.slug,
|
||||
module_name: moduleData.value.module_name,
|
||||
version: moduleData.value.latest_module_version,
|
||||
checksum_sha256: moduleData.value.latest_module_checksum_sha256,
|
||||
},
|
||||
(step) => {
|
||||
const existing = installationSteps.find(
|
||||
|
||||
@@ -10,58 +10,26 @@
|
||||
<BaseCard class="mt-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h6 class="text-heading text-lg font-medium">Marketplace Access</h6>
|
||||
<h6 class="text-heading text-lg font-medium">Marketplace access</h6>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Public modules are always available. Add your marketplace token to unlock premium modules tied to your website subscription.
|
||||
Pair this InvoiceShelf instance with your marketplace account. The device credential stays encrypted on this server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-medium"
|
||||
:class="statusClass"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid mt-6 lg:grid-cols-2">
|
||||
<form class="space-y-4" @submit.prevent="submitApiToken">
|
||||
<BaseInputGroup
|
||||
:label="$t('modules.api_token')"
|
||||
required
|
||||
:error="v$.api_token.$error ? String(v$.api_token.$errors[0]?.$message) : undefined"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="moduleStore.currentUser.api_token"
|
||||
:invalid="v$.api_token.$error"
|
||||
@input="v$.api_token.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BaseButton :loading="isSaving" type="submit">
|
||||
<template #left="slotProps">
|
||||
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
Save Token
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="moduleStore.apiToken"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="clearApiToken"
|
||||
>
|
||||
Clear Token
|
||||
</BaseButton>
|
||||
|
||||
<a :href="tokenPageUrl" target="_blank" rel="noopener" class="inline-flex">
|
||||
<BaseButton variant="primary-outline" type="button">
|
||||
Manage Token
|
||||
</BaseButton>
|
||||
</a>
|
||||
<div v-if="pairingCode" class="mt-4 space-y-1 text-sm text-body">
|
||||
<p>Enter code <strong>{{ pairingCode.user_code }}</strong> at the marketplace verification page.</p>
|
||||
<a v-if="pairingCode.verification_uri_complete || pairingCode.verification_uri" class="text-primary-600 underline" :href="pairingCode.verification_uri_complete || pairingCode.verification_uri || undefined" target="_blank" rel="noopener">Open verification page</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<BaseButton v-if="!moduleStore.marketplacePairing?.paired" :loading="isPairing" @click="startPairing">
|
||||
Pair marketplace
|
||||
</BaseButton>
|
||||
<BaseButton v-if="pairingCode" variant="primary-outline" :loading="isPolling" @click="pollPairing">
|
||||
I have approved this device
|
||||
</BaseButton>
|
||||
<BaseButton v-if="moduleStore.marketplacePairing?.paired" variant="primary-outline" @click="disconnect">
|
||||
Disconnect
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
@@ -70,28 +38,16 @@
|
||||
<BaseTab :title="$t('general.all')" filter="" />
|
||||
<BaseTab :title="$t('modules.installed')" filter="INSTALLED" />
|
||||
</BaseTabGroup>
|
||||
|
||||
<div
|
||||
v-if="isFetchingModule"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-if="isFetchingModule" class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-for="n in 3" :key="n" class="h-80 bg-surface-tertiary rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="filteredModules.length"
|
||||
class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<div v-for="(mod, idx) in filteredModules" :key="idx">
|
||||
<ModuleCard :data="mod" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-24">
|
||||
<label class="flex items-center justify-center text-muted">
|
||||
{{ $t('modules.no_modules_installed') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="filteredModules.length" class="grid mt-6 w-full grid-cols-1 items-start gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ModuleCard v-for="mod in filteredModules" :key="mod.slug" :data="mod" />
|
||||
</div>
|
||||
<div v-else class="mt-24">
|
||||
<label class="flex items-center justify-center text-muted">
|
||||
{{ activeTab === 'INSTALLED' ? $t('modules.no_modules_installed') : 'No marketplace modules are available yet.' }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
@@ -99,103 +55,26 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, minLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { useModuleStore } from '../store'
|
||||
import ModuleCard from '../components/ModuleCard.vue'
|
||||
import type { Module } from '../../../../types/domain/module'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import type { MarketplacePairingCode } from '@/scripts/api/services/module.service'
|
||||
import type { Module } from '@/scripts/types/domain/module'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
|
||||
const moduleStore = useModuleStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref('')
|
||||
const isFetchingModule = ref(false)
|
||||
const isPairing = ref(false)
|
||||
const isPolling = ref(false)
|
||||
const pairingCode = ref<MarketplacePairingCode | null>(null)
|
||||
|
||||
const activeTab = ref<string>('')
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isFetchingModule = ref<boolean>(false)
|
||||
|
||||
const rules = computed(() => ({
|
||||
api_token: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.name_min_length', { count: 3 }),
|
||||
minLength(3),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => moduleStore.currentUser),
|
||||
)
|
||||
|
||||
const filteredModules = computed<Module[]>(() => {
|
||||
if (activeTab.value === 'INSTALLED') {
|
||||
return moduleStore.installedModules
|
||||
}
|
||||
return moduleStore.modules
|
||||
})
|
||||
|
||||
const statusLabel = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'Invalid token'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'Premium modules unlocked'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'Connected'
|
||||
}
|
||||
|
||||
return 'Public modules only'
|
||||
})
|
||||
|
||||
const statusClass = computed<string>(() => {
|
||||
if (moduleStore.marketplaceStatus.invalidToken) {
|
||||
return 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.premium) {
|
||||
return 'bg-amber-100 text-amber-800'
|
||||
}
|
||||
|
||||
if (moduleStore.marketplaceStatus.authenticated) {
|
||||
return 'bg-green-100 text-green-700'
|
||||
}
|
||||
|
||||
return 'bg-surface-secondary text-muted'
|
||||
})
|
||||
|
||||
const baseUrl = computed<string>(() => {
|
||||
return String(globalStore.config?.base_url ?? '')
|
||||
})
|
||||
|
||||
const tokenPageUrl = computed<string>(() => {
|
||||
return `${baseUrl.value}/marketplace/token`
|
||||
})
|
||||
const filteredModules = computed<Module[]>(() => activeTab.value === 'INSTALLED'
|
||||
? moduleStore.installedModules
|
||||
: moduleStore.modules)
|
||||
|
||||
onMounted(async () => {
|
||||
const savedToken = String(globalStore.globalSettings?.api_token ?? '').trim() || null
|
||||
moduleStore.setApiToken(savedToken)
|
||||
|
||||
if (savedToken) {
|
||||
const response = await moduleStore.checkApiToken(savedToken)
|
||||
if (response.error === 'invalid_token') {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: 'Saved marketplace token is invalid. Public modules are shown until you update it.',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
}
|
||||
|
||||
await fetchModulesData()
|
||||
await Promise.all([moduleStore.fetchMarketplacePairing(), fetchModulesData()])
|
||||
})
|
||||
|
||||
async function fetchModulesData(): Promise<void> {
|
||||
@@ -207,55 +86,34 @@ async function fetchModulesData(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApiToken(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
if (v$.value.$invalid) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
async function startPairing(): Promise<void> {
|
||||
isPairing.value = true
|
||||
try {
|
||||
const token = moduleStore.currentUser.api_token ?? ''
|
||||
const response = await moduleStore.checkApiToken(token)
|
||||
|
||||
if (!response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: response.error === 'invalid_token'
|
||||
? 'Invalid marketplace token'
|
||||
: 'Unable to validate marketplace token',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: token,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token saved',
|
||||
})
|
||||
|
||||
moduleStore.setApiToken(token)
|
||||
await fetchModulesData()
|
||||
pairingCode.value = await moduleStore.startMarketplacePairing()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
isPairing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearApiToken(): Promise<void> {
|
||||
await globalStore.updateGlobalSettings({
|
||||
data: {
|
||||
settings: {
|
||||
api_token: null,
|
||||
},
|
||||
},
|
||||
message: 'Marketplace token cleared',
|
||||
})
|
||||
async function pollPairing(): Promise<void> {
|
||||
isPolling.value = true
|
||||
try {
|
||||
const result = await moduleStore.pollMarketplacePairing()
|
||||
if (result.status === 'paired') {
|
||||
pairingCode.value = null
|
||||
notificationStore.showNotification({ type: 'success', message: 'Marketplace paired' })
|
||||
await fetchModulesData()
|
||||
} else {
|
||||
notificationStore.showNotification({ type: 'info', message: 'Waiting for marketplace approval' })
|
||||
}
|
||||
} finally {
|
||||
isPolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
moduleStore.setApiToken(null)
|
||||
moduleStore.clearMarketplaceStatus()
|
||||
v$.value.$reset()
|
||||
async function disconnect(): Promise<void> {
|
||||
await moduleStore.disconnectMarketplace()
|
||||
notificationStore.showNotification({ type: 'success', message: 'Marketplace disconnected' })
|
||||
await fetchModulesData()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user