feat: add secure module marketplace runtime (#745)

This commit is contained in:
Darko Gjorgjijoski
2026-08-05 03:49:42 +02:00
committed by GitHub
parent f322c2b74d
commit 8579e4f85f
79 changed files with 2084 additions and 1762 deletions
+44 -141
View File
@@ -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()
}