From cb683d6b3d0e47303de0ff5a29a365e13d50e0ee Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 9 May 2026 13:28:50 +0800 Subject: [PATCH] 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 --- app/composables/useLocale.ts | 4 ++ app/middleware/guest.ts | 12 ++++- app/pages/confirmation/[token].vue | 48 +++++++++++++++---- app/pages/login/index.vue | 11 ++++- .../public/bookings/[token]/cancel.post.ts | 3 ++ .../public/bookings/[token]/confirm.post.ts | 3 ++ .../public/bookings/[token]/payment.patch.ts | 3 ++ .../[token]/transaction-document.delete.ts | 3 ++ .../[token]/transaction-document.get.ts | 11 ++++- .../[token]/transaction-document.post.ts | 3 ++ server/utils/auth.ts | 14 ++++++ 11 files changed, 102 insertions(+), 13 deletions(-) diff --git a/app/composables/useLocale.ts b/app/composables/useLocale.ts index a7da718..8c77923 100644 --- a/app/composables/useLocale.ts +++ b/app/composables/useLocale.ts @@ -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': '座位列表', diff --git a/app/middleware/guest.ts b/app/middleware/guest.ts index fc1ebd5..dd32a9d 100644 --- a/app/middleware/guest.ts +++ b/app/middleware/guest.ts @@ -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)) }) diff --git a/app/pages/confirmation/[token].vue b/app/pages/confirmation/[token].vue index 3f0bd83..e2eef38 100644 --- a/app/pages/confirmation/[token].vue +++ b/app/pages/confirmation/[token].vue @@ -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" /> + +
-
+

@@ -540,7 +560,7 @@ async function cancelBookingConfirmation() { /> + +

diff --git a/app/pages/login/index.vue b/app/pages/login/index.vue index b0a866d..c34390f 100644 --- a/app/pages/login/index.vue +++ b/app/pages/login/index.vue @@ -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>) return } - await router.push(getDefaultAuthenticatedPath(user)) + await router.push(getSafeRedirectPath(route.query.redirect) || getDefaultAuthenticatedPath(user)) } async function onSubmit(event: FormSubmitEvent) { diff --git a/server/api/public/bookings/[token]/cancel.post.ts b/server/api/public/bookings/[token]/cancel.post.ts index 2e9ef5e..f19eb1c 100644 --- a/server/api/public/bookings/[token]/cancel.post.ts +++ b/server/api/public/bookings/[token]/cancel.post.ts @@ -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 httpError(404, 'Booking not found') } + await requireBookingManager(event, existingBooking) + if (existingBooking.status === 'confirmed') { return { booking: existingBooking, diff --git a/server/api/public/bookings/[token]/payment.patch.ts b/server/api/public/bookings/[token]/payment.patch.ts index 9932c0b..b2e8006 100644 --- a/server/api/public/bookings/[token]/payment.patch.ts +++ b/server/api/public/bookings/[token]/payment.patch.ts @@ -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 { 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) { diff --git a/server/api/public/bookings/[token]/transaction-document.post.ts b/server/api/public/bookings/[token]/transaction-document.post.ts index b7a6213..6e1f878 100644 --- a/server/api/public/bookings/[token]/transaction-document.post.ts +++ b/server/api/public/bookings/[token]/transaction-document.post.ts @@ -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) { + 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,