Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion frontend/src/components/schedule_overlap/ScheduleOverlap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2455,9 +2455,15 @@ export default {
if (!timeMin || !timeMax) return

// Fetch responses between timeMin and timeMax
const url = `/events/${
let url = `/events/${
this.event._id
}/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}`

// Add guestName query parameter if user is a guest
if (this.guestName && this.guestName.length > 0) {
url += `&guestName=${encodeURIComponent(this.guestName)}`
}

get(url)
.then((responses) => {
this.fetchedResponses = responses
Expand Down Expand Up @@ -2850,7 +2856,12 @@ export default {
payload.guest = true
payload.name = guestPayload.name
payload.email = guestPayload.email
// Store with event._id (current format used by guestNameKey)
localStorage[this.guestNameKey] = guestPayload.name
// Also store with shortId or _id (to match eventId prop format used in Event.vue)
// This allows refreshEvent() to read it immediately without needing event._id first
const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName`
localStorage[eventIdKey] = guestPayload.name
}
}

Expand Down Expand Up @@ -3515,7 +3526,11 @@ export default {
oldName: this.curGuestId,
newName,
})
// Store with event._id (current format used by guestNameKey)
localStorage[this.guestNameKey] = newName
// Also store with shortId or _id (to match eventId prop format used in Event.vue)
const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName`
localStorage[eventIdKey] = newName
Copy link
Member

@jonyTF jonyTF Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the point of eventIdKey?

Copy link
Member

@jonyTF jonyTF Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't seem to be used anywhere, seems redundant with guestNameKey

this.showInfo("Guest name updated successfully")
this.editGuestNameDialog = false
this.$emit("setCurGuestId", newName)
Expand Down
87 changes: 83 additions & 4 deletions frontend/src/views/Event.vue
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,44 @@ export default {
/** Refresh event details */
async refreshEvent() {
let sanitizedId = this.eventId.replaceAll(".", "")
this.event = await get(`/events/${sanitizedId}`)

// Try to get guest name from localStorage using eventId first (available immediately)
// This works because ScheduleOverlap now stores guest name with both keys
let guestName = null
if (typeof localStorage !== "undefined") {
// Try with eventId first (matches the key format used when storing in ScheduleOverlap)
const guestNameKeyFromEventId = `${sanitizedId}.guestName`
guestName = localStorage[guestNameKeyFromEventId]

// If not found and event is already loaded, try with event._id (for backward compatibility)
if (!guestName && this.event?._id) {
const guestNameKeyFromEventId2 = `${this.event._id}.guestName`
guestName = localStorage[guestNameKeyFromEventId2]
}
}

// Build URL with guestName if available
let url = `/events/${sanitizedId}`
if (guestName && guestName.length > 0) {
url += `?guestName=${encodeURIComponent(guestName)}`
}

// Make single request with guestName if available
this.event = await get(url)
processEvent(this.event)

// After loading event, if we didn't have guestName before but now we have event._id,
// check localStorage one more time with the correct _id format (unlikely this code below will execute but have it for safety)
if (!guestName && this.event?._id && typeof localStorage !== "undefined") {
const guestNameKey = `${this.event._id}.guestName`
const foundGuestName = localStorage[guestNameKey]
if (foundGuestName && foundGuestName.length > 0) {
// Make one more request with the correct guestName
url = `/events/${sanitizedId}?guestName=${encodeURIComponent(foundGuestName)}`
this.event = await get(url)
processEvent(this.event)
}
}
},

setAvailabilityAutomatically(calendarType = calendarTypes.GOOGLE) {
Expand Down Expand Up @@ -1055,9 +1091,27 @@ export default {
// Validation: Check timeIncrement exists, default to 15 if not
const timeIncrement = this.event.timeIncrement ?? 15

// Check if guestName is provided in payload - if so, force guest mode
// Security check: If blindAvailabilityEnabled is true and user is NOT the owner,
// reject any request with guestName parameter
const payloadGuestName = event.data?.payload?.guestName
const forceGuestMode = payloadGuestName && payloadGuestName.length > 0
const hasGuestName = payloadGuestName && payloadGuestName.length > 0

if (this.event.blindAvailabilityEnabled) {
// Check if user is owner: ownerId is only returned by backend if user is the owner
// So if ownerId exists and matches current user's ID, they are the owner
const isOwner = this.event.ownerId && this.authUser?._id === this.event.ownerId
if (!isOwner && hasGuestName) {
sendPluginError(
requestId,
command,
"Non-owners cannot set guest availability when 'Hide responses from respondents' is enabled."
)
return
}
}

// Check if guestName is provided in payload - if so, force guest mode
const forceGuestMode = hasGuestName

// Determine if current user is guest or logged-in
// If guestName is provided in payload, always treat as guest (ignore login status)
Expand All @@ -1072,7 +1126,11 @@ export default {
if (forceGuestMode) {
// guestName provided in payload - use it and store in localStorage
guestName = payloadGuestName
// Store with event._id (current format)
localStorage[guestNameKey] = guestName
// Also store with shortId or _id (to match eventId prop format)
const eventIdKey = `${this.event.shortId ?? this.event._id}.guestName`
localStorage[eventIdKey] = guestName

// If event collects emails, require guestEmail in payload
if (this.event.collectEmails) {
Expand Down Expand Up @@ -1470,7 +1528,28 @@ export default {

try {
// Fetch responses between timeMin and timeMax
const url = `/events/${sanitizedId}/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}`
//TODO: update this with the new getResponses model

// Try to get guest name from localStorage
let guestName = null
if (typeof localStorage !== "undefined") {
// Try with eventId first (matches the key format used when storing in ScheduleOverlap)
const guestNameKeyFromEventId = `${sanitizedId}.guestName`
guestName = localStorage[guestNameKeyFromEventId]

// If not found and event is already loaded, try with event._id (for backward compatibility)
if (!guestName && this.event?._id) {
const guestNameKeyFromEventId2 = `${this.event._id}.guestName`
guestName = localStorage[guestNameKeyFromEventId2]
}
}

// Build URL with guestName if available
let url = `/events/${sanitizedId}/responses?timeMin=${timeMin.toISOString()}&timeMax=${timeMax.toISOString()}`
if (guestName && guestName.length > 0) {
url += `&guestName=${encodeURIComponent(guestName)}`
}

const responses = await get(url)

// Build response object with all users' slots
Expand Down
146 changes: 143 additions & 3 deletions server/routes/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package routes

import (
"context"
"encoding/json" //TODO: remove this before committing
"fmt"
"net/http"
"time"
Expand Down Expand Up @@ -522,8 +523,82 @@ func getEvent(c *gin.Context) {
event.Attendees = &attendees
}

// Create a copy of the event with responses in map format
c.JSON(http.StatusOK, event)
// Update event.ResponsesMap to match the final responsesMap
event.ResponsesMap = responsesMap

// Apply privacy logic based on blindAvailabilityEnabled
if !utils.Coalesce(event.BlindAvailabilityEnabled) {
// Blind availability is NOT enabled - return response as-is
// Log response body
responseJSON, err := json.MarshalIndent(event, "", " ")
if err != nil {
logger.StdErr.Printf("Failed to marshal event for logging: %v\n", err)
}
_ = responseJSON
c.JSON(http.StatusOK, event)
return
}

// Blind availability IS enabled - apply privacy filtering
ownerSesh := event.OwnerId.Hex()
session := sessions.Default(c)
userIdInterface := session.Get("userId")
var userSesh string
if userIdInterface != nil {
userSesh = userIdInterface.(string)
}
guestName := c.Query("guestName")

var privatizedResponse map[string]interface{}
var err error

if userSesh != "" {
// User session exists (user is logged in)
if ownerSesh == userSesh {
// User is the owner - return response as-is
privatizedResponse, err = utils.PrivatizeEventResponse(event, []string{}, []utils.PartialOmission{})
} else {
// User is NOT the owner - privatize response
privateFields := []string{"ownerId", "numResponses"}
partialOmissions := []utils.PartialOmission{
{
FieldName: "responses",
KeepKey: userSesh,
},
}
privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, partialOmissions)
}
} else if guestName != "" {
// Guest name query parameter exists
privateFields := []string{"ownerId", "numResponses"}
partialOmissions := []utils.PartialOmission{
{
FieldName: "responses",
KeepKey: guestName,
},
}
privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, partialOmissions)
} else {
// No session, no guest name - remove all private fields
privateFields := []string{"ownerId", "numResponses", "responses", "remindees"}
privatizedResponse, err = utils.PrivatizeEventResponse(event, privateFields, []utils.PartialOmission{})
}

if err != nil {
logger.StdErr.Printf("Failed to privatize event response: %v\n", err)
// Fall back to returning the original event if privatization fails
c.JSON(http.StatusOK, event)
return
}

// Log response body
responseJSON, err := json.MarshalIndent(privatizedResponse, "", " ")
if err != nil {
logger.StdErr.Printf("Failed to marshal privatized response for logging: %v\n", err)
}
_ = responseJSON
// Return the privatized response
c.JSON(http.StatusOK, privatizedResponse)
}

// @Summary Gets responses for an event, filtering availability to be within the date ranges
Expand Down Expand Up @@ -584,7 +659,52 @@ func getResponses(c *gin.Context) {
responsesMap[userId] = response
}

c.JSON(http.StatusOK, responsesMap)


// Apply privacy logic based on blindAvailabilityEnabled
if !utils.Coalesce(event.BlindAvailabilityEnabled) {
// Blind availability is NOT enabled - return response as-is
c.JSON(http.StatusOK, responsesMap)
return
}

// Blind availability IS enabled - apply privacy filtering
ownerSesh := event.OwnerId.Hex()
session := sessions.Default(c)
userIdInterface := session.Get("userId")
var userSesh string
if userIdInterface != nil {
userSesh = userIdInterface.(string)
}
guestName := c.Query("guestName")
if userSesh != "" {
// User session exists (user is logged in)
if ownerSesh == userSesh {
// User is the owner - return response as-is
c.JSON(http.StatusOK, responsesMap)
return
} else {
// User is NOT the owner - return only their own response
filteredMap := make(map[string]*models.Response)
if userResponse, exists := responsesMap[userSesh]; exists {
filteredMap[userSesh] = userResponse
}
c.JSON(http.StatusOK, filteredMap)
return
}
} else if guestName != "" {
// Guest name query parameter exists - return only that guest's response
filteredMap := make(map[string]*models.Response)
if guestResponse, exists := responsesMap[guestName]; exists {
filteredMap[guestName] = guestResponse
}
c.JSON(http.StatusOK, filteredMap)
return
} else {
// No session, no guest name - return empty map
c.JSON(http.StatusOK, make(map[string]*models.Response))
return
}
}

// @Summary Updates the current user's availability
Expand Down Expand Up @@ -624,6 +744,26 @@ func updateEventResponse(c *gin.Context) {
c.JSON(http.StatusNotFound, responses.Error{Error: errs.EventNotFound})
return
}

// Security check: If blindAvailabilityEnabled is true, non-owners cannot set guest availability
//NOTE: this ONLY stops a user from setting guest availability from their account (via setSlots), somebody could still
// go on incognito and set guest availability.
if utils.Coalesce(event.BlindAvailabilityEnabled) {
ownerSesh := event.OwnerId.Hex()
userIdInterface := session.Get("userId")
var userSesh string
if userIdInterface != nil {
userSesh = userIdInterface.(string)
}

// If user is logged in and NOT the owner, and they're trying to set guest availability, block it
if userSesh != "" && ownerSesh != userSesh && *payload.Guest {
c.JSON(http.StatusForbidden, responses.Error{Error: errs.UserNotEventOwner})
c.Abort()
return
}
}

eventResponses := db.GetEventResponses(event.Id.Hex())

var userIdString string
Expand Down
Loading