feat(bookings): restrict management to assigned PIC or super admin

Secure API endpoints with requireBookingManager authorization check
Update confirmation page to prompt for login if unauthorized
Add safe redirect handling to login and guest middleware
This commit is contained in:
2026-05-09 13:28:50 +08:00
parent a56a6706b0
commit cb683d6b3d
11 changed files with 102 additions and 13 deletions

View File

@@ -109,6 +109,8 @@ const messages = {
'confirm.alreadyPendingDescription': 'This booking was already pending confirmation.',
'confirm.cancelledDescription': 'The booking has been returned to pending status.',
'confirm.cancelFailed': 'Cancellation failed',
'confirm.signInToManage': 'Sign in to manage this booking',
'confirm.managementRestricted': 'Only the assigned PIC or Super Admin can manage this booking.',
'receipt.badge': 'Ticket Receipt',
'receipt.mainQr': 'Main QR',
'receipt.seatList': 'Seat List',
@@ -277,6 +279,8 @@ const messages = {
'confirm.alreadyPendingDescription': '此预订已经处于待确认状态。',
'confirm.cancelledDescription': '预订已回到待确认状态。',
'confirm.cancelFailed': '取消失败',
'confirm.signInToManage': '登录以管理此预订',
'confirm.managementRestricted': '只有指定负责人或 Super Admin 可以管理此预订。',
'receipt.badge': '票券收据',
'receipt.mainQr': '主二维码',
'receipt.seatList': '座位列表',

View File

@@ -1,6 +1,14 @@
import { getDefaultAuthenticatedPath } from '~~/shared/auth'
export default defineNuxtRouteMiddleware(async () => {
function getSafeRedirectPath(value: unknown) {
const redirect = Array.isArray(value) ? value[0] : value
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: null
}
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuth()
await auth.fetchSession()
@@ -8,5 +16,5 @@ export default defineNuxtRouteMiddleware(async () => {
return
}
return navigateTo(getDefaultAuthenticatedPath(auth.user.value))
return navigateTo(getSafeRedirectPath(to.query.redirect) || getDefaultAuthenticatedPath(auth.user.value))
})

View File

@@ -18,6 +18,7 @@ import { formatDateTime } from '../../utils/formatters'
const route = useRoute()
const toast = useToast()
const apiClient = useApiClient()
const auth = useAuth()
const { locale, t } = useLocale()
const token = String(route.params.token || '')
@@ -27,6 +28,8 @@ const savingPayment = ref(false)
const uploadingTransactionDocument = ref(false)
const deletingTransactionDocument = ref(false)
await auth.fetchSession()
let initialBooking: PublicBooking
try {
@@ -50,6 +53,12 @@ const statusColor = computed(() => booking.value.status === 'confirmed' ? 'succe
const ticketLabel = computed(() => booking.value.ticketLabel || booking.value.ticketType.toUpperCase())
const totalFormatted = computed(() => formatBookingCurrency(booking.value.totalPrice, locale.value))
const receiptPath = computed(() => `/receipt/${booking.value.receiptToken}`)
const signInPath = computed(() => `/login?redirect=${encodeURIComponent(route.fullPath)}`)
const canManageBooking = computed(() => {
const user = auth.user.value
return Boolean(user && (user.role === 'super_admin' || user.id === booking.value.personInChargeId))
})
const transactionDocumentLimit = 10 * 1024 * 1024
const transactionDocumentAccept = '.pdf,.jpg,.jpeg,.png,.webp,.heic,.heif,application/pdf,image/jpeg,image/png,image/webp,image/heic,image/heif'
const selectedTransactionDocumentName = computed(() => transactionDocumentFile.value?.name || '')
@@ -149,6 +158,10 @@ function formatFileSize(size: number) {
}
function onTransactionDocumentChange(event: Event) {
if (!canManageBooking.value) {
return
}
const input = event.target as HTMLInputElement
transactionDocumentFile.value = input.files?.[0] ?? null
}
@@ -159,7 +172,7 @@ function applyBooking(nextBooking: PublicBooking) {
}
async function savePaymentDetails() {
if (savingPayment.value || booking.value.status !== 'pending') {
if (!canManageBooking.value || savingPayment.value || booking.value.status !== 'pending') {
return booking.value
}
@@ -185,7 +198,7 @@ async function savePaymentDetails() {
}
async function uploadTransactionDocument(options: { silent?: boolean } = {}) {
if (!transactionDocumentFile.value || uploadingTransactionDocument.value || booking.value.status !== 'pending') {
if (!canManageBooking.value || !transactionDocumentFile.value || uploadingTransactionDocument.value || booking.value.status !== 'pending') {
return booking.value
}
@@ -249,7 +262,7 @@ async function uploadTransactionDocument(options: { silent?: boolean } = {}) {
}
async function deleteTransactionDocument() {
if (!booking.value.transactionDocument || deletingTransactionDocument.value || booking.value.status !== 'pending') {
if (!canManageBooking.value || !booking.value.transactionDocument || deletingTransactionDocument.value || booking.value.status !== 'pending') {
return
}
@@ -290,7 +303,7 @@ async function deleteTransactionDocument() {
}
async function confirmBooking() {
if (booking.value.status === 'confirmed') {
if (!canManageBooking.value || booking.value.status === 'confirmed') {
return
}
@@ -334,7 +347,7 @@ async function confirmBooking() {
}
async function cancelBookingConfirmation() {
if (booking.value.status !== 'confirmed') {
if (!canManageBooking.value || booking.value.status !== 'confirmed') {
return
}
@@ -413,6 +426,13 @@ async function cancelBookingConfirmation() {
icon="i-lucide-badge-check"
/>
<UAlert
v-if="!canManageBooking"
:title="auth.user.value ? t('confirm.managementRestricted') : t('confirm.signInToManage')"
color="warning"
icon="i-lucide-lock-keyhole"
/>
<div class="overflow-hidden rounded-lg border border-default">
<div
v-for="row in detailRows"
@@ -437,7 +457,7 @@ async function cancelBookingConfirmation() {
</div>
</div>
<div class="space-y-3 rounded-lg border border-default p-4">
<div v-if="canManageBooking" class="space-y-3 rounded-lg border border-default p-4">
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-sm font-semibold text-highlighted">
@@ -540,7 +560,7 @@ async function cancelBookingConfirmation() {
/>
<UButton
v-if="booking.status === 'confirmed'"
v-if="canManageBooking && booking.status === 'confirmed'"
:to="receiptPath"
:label="t('confirm.openReceipt')"
color="neutral"
@@ -550,7 +570,7 @@ async function cancelBookingConfirmation() {
/>
<UButton
v-if="booking.status === 'pending'"
v-if="canManageBooking && booking.status === 'pending'"
:label="t('confirm.confirmBooking')"
icon="i-lucide-check-check"
class="justify-center"
@@ -560,7 +580,7 @@ async function cancelBookingConfirmation() {
/>
<UButton
v-else
v-else-if="canManageBooking"
:label="t('confirm.cancelConfirmation')"
color="error"
variant="outline"
@@ -570,6 +590,16 @@ async function cancelBookingConfirmation() {
:disabled="savingPayment || uploadingTransactionDocument || deletingTransactionDocument"
@click="cancelBookingConfirmation"
/>
<UButton
v-else-if="!auth.user.value"
:to="signInPath"
:label="t('confirm.signInToManage')"
color="primary"
variant="outline"
icon="i-lucide-log-in"
class="justify-center"
/>
</div>
</UCard>
</div>

View File

@@ -128,6 +128,7 @@ useSeoMeta({
})
const toast = useToast()
const route = useRoute()
const router = useRouter()
const auth = useAuth()
const apiClient = useApiClient()
@@ -141,6 +142,14 @@ const form = reactive({
const passwordPending = ref(false)
const passkeyPending = ref(false)
function getSafeRedirectPath(value: unknown) {
const redirect = Array.isArray(value) ? value[0] : value
return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
? redirect
: null
}
function validateLogin(state: typeof form): FormError[] {
const errors: FormError[] = []
@@ -160,7 +169,7 @@ async function finishLogin(user: Awaited<ReturnType<typeof auth.fetchSession>>)
return
}
await router.push(getDefaultAuthenticatedPath(user))
await router.push(getSafeRedirectPath(route.query.redirect) || getDefaultAuthenticatedPath(user))
}
async function onSubmit(event: FormSubmitEvent<typeof form>) {

View File

@@ -1,5 +1,6 @@
import type { CancelBookingConfirmationResponse } from '~~/shared/booking'
import { requireBookingManager } from '../../../../utils/auth'
import { cancelBookingConfirmationByConfirmationToken, getBookingByConfirmationToken } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
@@ -11,6 +12,8 @@ export default defineEventHandler(async (event): Promise<CancelBookingConfirmati
httpError(404, 'Booking not found')
}
await requireBookingManager(event, existingBooking)
if (existingBooking.status === 'pending') {
return {
booking: existingBooking,

View File

@@ -1,5 +1,6 @@
import type { ConfirmBookingResponse } from '~~/shared/booking'
import { requireBookingManager } from '../../../../utils/auth'
import { confirmBookingByConfirmationToken, getBookingByConfirmationToken, getBookingInventorySummary } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import { sendBookingTicketReceiptViaWhatsApp } from '../../../../utils/whatsapp'
@@ -12,6 +13,8 @@ export default defineEventHandler(async (event): Promise<ConfirmBookingResponse>
httpError(404, 'Booking not found')
}
await requireBookingManager(event, existingBooking)
if (existingBooking.status === 'confirmed') {
return {
booking: existingBooking,

View File

@@ -1,5 +1,6 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { requireBookingManager } from '../../../../utils/auth'
import {
clearBookingTransactionDocumentByConfirmationToken,
getBookingByConfirmationToken,
@@ -17,6 +18,8 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
httpError(404, 'Booking not found')
}
await requireBookingManager(event, existingBooking)
if (existingBooking.status !== 'pending') {
httpError(409, 'Payment details can only be changed before confirmation')
}

View File

@@ -1,5 +1,6 @@
import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { requireBookingManager } from '../../../../utils/auth'
import {
clearBookingTransactionDocumentByConfirmationToken,
getBookingByConfirmationToken
@@ -15,6 +16,8 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
httpError(404, 'Booking not found')
}
await requireBookingManager(event, booking)
if (booking.status !== 'pending') {
httpError(409, 'Transaction document can only be changed before confirmation')
}

View File

@@ -1,6 +1,7 @@
import { sendStream, setHeader } from 'h3'
import { getBookingTransactionDocumentByConfirmationToken } from '../../../../utils/booking-repository'
import { requireBookingManager } from '../../../../utils/auth'
import { getBookingByConfirmationToken, getBookingTransactionDocumentByConfirmationToken } from '../../../../utils/booking-repository'
import { getRequiredRouteParam, httpError } from '../../../../utils/http'
import {
getSafeDownloadName,
@@ -9,6 +10,14 @@ import {
export default defineEventHandler(async (event) => {
const token = getRequiredRouteParam(event, 'token', 'Confirmation token')
const booking = await getBookingByConfirmationToken(token)
if (!booking) {
httpError(404, 'Booking not found')
}
await requireBookingManager(event, booking)
const document = await getBookingTransactionDocumentByConfirmationToken(token)
if (!document) {

View File

@@ -2,6 +2,7 @@ import type { UpdateBookingDetailsResponse } from '~~/shared/booking'
import { getHeader, readMultipartFormData } from 'h3'
import { requireBookingManager } from '../../../../utils/auth'
import {
getBookingByConfirmationToken,
replaceBookingTransactionDocumentByConfirmationToken
@@ -22,6 +23,8 @@ export default defineEventHandler(async (event): Promise<UpdateBookingDetailsRes
httpError(404, 'Booking not found')
}
await requireBookingManager(event, booking)
if (booking.status !== 'pending') {
httpError(409, 'Transaction document can only be changed before confirmation')
}

View File

@@ -1,4 +1,5 @@
import type { H3Event } from 'h3'
import type { PublicBooking } from '~~/shared/booking'
import { normalizeUsername, type UserRole } from '~~/shared/auth'
@@ -54,6 +55,19 @@ export async function requireRole(event: H3Event, role: UserRole) {
return auth
}
export async function requireBookingManager(event: H3Event, booking: Pick<PublicBooking, 'personInChargeId'>) {
const auth = await requireAuth(event)
if (auth.user.role !== 'super_admin' && auth.user.id !== booking.personInChargeId) {
throw createError({
statusCode: 403,
statusMessage: 'You are not allowed to manage this booking'
})
}
return auth
}
export async function signInUser(event: H3Event, user: UserAuthRecord, remember: boolean) {
await createUserSession(event, {
userId: user.id,