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,