mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-04 06:11:05 +00:00
crypto.randomUUID() and navigator.clipboard are both [SecureContext]- gated, so neither exists on a plain-HTTP origin that isn't localhost. That covers the dev host (http://invoiceshelf.test) and any self-hosted install reached over a hostname or LAN IP — a large share of them. generateClientId() called crypto.randomUUID() unguarded. It runs during Pinia store construction via the invoice, estimate and recurring-invoice stub factories, so on those origins it threw a TypeError before the store existed and took the document screens down with it. The value is only a placeholder identity for a row that has no server id yet — the server assigns the real one on save, which is why DocumentItem and DocumentTax type it `number | string`. It never needed randomness, so it is now a session counter: no crypto, no fallback branch, works everywhere. PaymentDropdown.copyPdfUrl() had a textarea fallback attached with .catch(), which cannot fire — on a non-secure origin navigator.clipboard is undefined, so `.writeText` throws on property access before any promise exists. Test up front instead, matching the guard the invoice and estimate dropdowns already use.
171 lines
4.5 KiB
Vue
171 lines
4.5 KiB
Vue
<template>
|
|
<BaseDropdown :content-loading="contentLoading">
|
|
<template #activator>
|
|
<BaseButton v-if="isDetailView" variant="primary">
|
|
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
|
|
</BaseButton>
|
|
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
|
|
</template>
|
|
|
|
<!-- Copy PDF url -->
|
|
<BaseDropdownItem
|
|
v-if="isDetailView && canView"
|
|
class="rounded-md"
|
|
@click="copyPdfUrl"
|
|
>
|
|
<BaseIcon
|
|
name="LinkIcon"
|
|
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
|
/>
|
|
{{ $t('general.copy_pdf_url') }}
|
|
</BaseDropdownItem>
|
|
|
|
<!-- Edit Payment -->
|
|
<router-link
|
|
v-if="canEdit"
|
|
:to="`/admin/payments/${row.id}/edit`"
|
|
>
|
|
<BaseDropdownItem>
|
|
<BaseIcon
|
|
name="PencilIcon"
|
|
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
|
/>
|
|
{{ $t('general.edit') }}
|
|
</BaseDropdownItem>
|
|
</router-link>
|
|
|
|
<!-- View Payment -->
|
|
<router-link
|
|
v-if="!isDetailView && canView"
|
|
:to="`/admin/payments/${row.id}/view`"
|
|
>
|
|
<BaseDropdownItem>
|
|
<BaseIcon
|
|
name="EyeIcon"
|
|
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
|
/>
|
|
{{ $t('general.view') }}
|
|
</BaseDropdownItem>
|
|
</router-link>
|
|
|
|
<!-- Send Payment -->
|
|
<BaseDropdownItem
|
|
v-if="!isDetailView && canSend"
|
|
@click="sendPayment"
|
|
>
|
|
<BaseIcon
|
|
name="PaperAirplaneIcon"
|
|
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
|
/>
|
|
{{ $t('payments.send_payment') }}
|
|
</BaseDropdownItem>
|
|
|
|
<!-- Delete Payment -->
|
|
<BaseDropdownItem v-if="canDelete" @click="removePayment">
|
|
<BaseIcon
|
|
name="TrashIcon"
|
|
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
|
/>
|
|
{{ $t('general.delete') }}
|
|
</BaseDropdownItem>
|
|
</BaseDropdown>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { usePaymentStore } from '../store'
|
|
import { useDialogStore } from '../../../../stores/dialog.store'
|
|
import { useModalStore } from '../../../../stores/modal.store'
|
|
import type { Payment } from '../../../../types/domain/payment'
|
|
|
|
interface TableRef {
|
|
refresh: () => void
|
|
}
|
|
|
|
interface Props {
|
|
row: Payment | Record<string, unknown>
|
|
table?: TableRef | null
|
|
contentLoading?: boolean
|
|
canEdit?: boolean
|
|
canView?: boolean
|
|
canDelete?: boolean
|
|
canSend?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
table: null,
|
|
contentLoading: false,
|
|
canEdit: false,
|
|
canView: false,
|
|
canDelete: false,
|
|
canSend: false,
|
|
})
|
|
|
|
const paymentStore = usePaymentStore()
|
|
const dialogStore = useDialogStore()
|
|
const modalStore = useModalStore()
|
|
const { t } = useI18n()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const isDetailView = computed<boolean>(() => route.name === 'payments.view')
|
|
|
|
function removePayment(): void {
|
|
dialogStore
|
|
.openDialog({
|
|
title: t('general.are_you_sure'),
|
|
message: t('payments.confirm_delete'),
|
|
yesLabel: t('general.ok'),
|
|
noLabel: t('general.cancel'),
|
|
variant: 'danger',
|
|
hideNoButton: false,
|
|
size: 'lg',
|
|
})
|
|
.then(async (res: boolean) => {
|
|
if (res) {
|
|
const payment = props.row as Payment
|
|
await paymentStore.deletePayment({ ids: [payment.id] })
|
|
router.push('/admin/payments')
|
|
props.table?.refresh()
|
|
}
|
|
})
|
|
}
|
|
|
|
function copyPdfUrl(): void {
|
|
const payment = props.row as Payment
|
|
const pdfUrl = `${window.location.origin}/payments/pdf/${payment.unique_hash}`
|
|
|
|
// navigator.clipboard is [SecureContext]-gated, so on a plain-HTTP origin it is
|
|
// undefined and `.writeText` throws on property access — before any promise
|
|
// exists for .catch() to handle. Test up front, as the invoice and estimate
|
|
// dropdowns already do.
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
navigator.clipboard.writeText(pdfUrl)
|
|
return
|
|
}
|
|
|
|
const textarea = document.createElement('textarea')
|
|
textarea.value = pdfUrl
|
|
textarea.style.position = 'fixed'
|
|
textarea.style.opacity = '0'
|
|
document.body.appendChild(textarea)
|
|
textarea.focus()
|
|
textarea.select()
|
|
document.execCommand('copy')
|
|
document.body.removeChild(textarea)
|
|
}
|
|
|
|
function sendPayment(): void {
|
|
const payment = props.row as Payment
|
|
modalStore.openModal({
|
|
title: t('payments.send_payment'),
|
|
componentName: 'SendPaymentModal',
|
|
id: payment.id,
|
|
data: payment,
|
|
variant: 'lg',
|
|
})
|
|
}
|
|
</script>
|