From ec8e8911ce19ce4cb01f1d6efae65d278a66f13c Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Fri, 6 Mar 2026 12:30:05 +0200 Subject: [PATCH] Security Updates --- src/handlers/room_api.go | 24 ++++-- src/models/room_setup.go | 2 + src/state/manager.go | 157 ++++++++++++++++++++++++++++++--------- src/state/types.go | 57 +++++++++++--- src/state/utils.go | 7 ++ src/templates/index.html | 4 + static/js/config.js | 9 ++- static/js/room.js | 95 ++++++++++++++++------- 8 files changed, 277 insertions(+), 78 deletions(-) diff --git a/src/handlers/room_api.go b/src/handlers/room_api.go index 3f2fe72..0d5eb97 100644 --- a/src/handlers/room_api.go +++ b/src/handlers/room_api.go @@ -27,6 +27,7 @@ type createRoomRequest struct { AllowSpectators bool `json:"allowSpectators"` AnonymousVoting bool `json:"anonymousVoting"` AutoReset bool `json:"autoReset"` + AllowVoteChange *bool `json:"allowVoteChange"` RevealMode string `json:"revealMode"` VotingTimeoutSec int `json:"votingTimeoutSec"` Password string `json:"password"` @@ -34,6 +35,7 @@ type createRoomRequest struct { type joinRoomRequest struct { ParticipantID string `json:"participantId"` + SessionToken string `json:"sessionToken"` Username string `json:"username"` Role string `json:"role"` Password string `json:"password"` @@ -42,11 +44,13 @@ type joinRoomRequest struct { type voteRequest struct { ParticipantID string `json:"participantId"` + SessionToken string `json:"sessionToken"` Card string `json:"card"` } type adminActionRequest struct { ParticipantID string `json:"participantId"` + SessionToken string `json:"sessionToken"` } func (h *RoomAPIHandler) CreateRoom(c *gin.Context) { @@ -64,6 +68,7 @@ func (h *RoomAPIHandler) CreateRoom(c *gin.Context) { AllowSpectators: req.AllowSpectators, AnonymousVoting: req.AnonymousVoting, AutoReset: req.AutoReset, + AllowVoteChange: req.AllowVoteChange, RevealMode: req.RevealMode, VotingTimeoutSec: req.VotingTimeoutSec, Password: req.Password, @@ -85,6 +90,7 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) { result, err := h.manager.JoinRoom(c.Param("roomID"), state.JoinRoomInput{ ParticipantID: req.ParticipantID, + SessionToken: req.SessionToken, Username: req.Username, Role: req.Role, Password: req.Password, @@ -101,12 +107,17 @@ func (h *RoomAPIHandler) JoinRoom(c *gin.Context) { func (h *RoomAPIHandler) StreamEvents(c *gin.Context) { roomID := c.Param("roomID") participantID := c.Query("participantId") + sessionToken := c.Query("sessionToken") if participantID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "participantId is required"}) return } + if sessionToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sessionToken is required"}) + return + } - stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID) + stream, initial, unsubscribe, err := h.manager.Subscribe(roomID, participantID, sessionToken) if err != nil { h.writeStateError(c, err) return @@ -169,7 +180,7 @@ func (h *RoomAPIHandler) CastVote(c *gin.Context) { return } - err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.Card) + err := h.manager.CastVote(c.Param("roomID"), req.ParticipantID, req.SessionToken, req.Card) if err != nil { h.writeStateError(c, err) return @@ -193,21 +204,21 @@ func (h *RoomAPIHandler) LeaveRoom(c *gin.Context) { return } - if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID); err != nil { + if err := h.manager.LeaveRoom(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil { h.writeStateError(c, err) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } -func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string) error) { +func (h *RoomAPIHandler) handleAdminAction(c *gin.Context, fn func(string, string, string) error) { var req adminActionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } - if err := fn(c.Param("roomID"), req.ParticipantID); err != nil { + if err := fn(c.Param("roomID"), req.ParticipantID, req.SessionToken); err != nil { h.writeStateError(c, err) return } @@ -234,6 +245,9 @@ func (h *RoomAPIHandler) writeStateError(c *gin.Context, err error) { case errors.Is(err, state.ErrPasswordRequired): status = http.StatusUnauthorized message = err.Error() + case errors.Is(err, state.ErrVoteChangeLocked): + status = http.StatusForbidden + message = err.Error() case errors.Is(err, state.ErrSpectatorsBlocked), errors.Is(err, state.ErrInvalidCard), errors.Is(err, state.ErrInvalidRole): status = http.StatusBadRequest message = err.Error() diff --git a/src/models/room_setup.go b/src/models/room_setup.go index e04a0d5..782eb55 100644 --- a/src/models/room_setup.go +++ b/src/models/room_setup.go @@ -10,6 +10,7 @@ type RoomSetupPageData struct { AllowSpectators bool AnonymousVoting bool AutoResetCards bool + AllowVoteChange bool DefaultStatus string } @@ -24,6 +25,7 @@ func DefaultRoomSetupPageData() RoomSetupPageData { AllowSpectators: true, AnonymousVoting: true, AutoResetCards: true, + AllowVoteChange: true, DefaultStatus: "Ready to create room.", } } diff --git a/src/state/manager.go b/src/state/manager.go index 77efae0..da6b863 100644 --- a/src/state/manager.go +++ b/src/state/manager.go @@ -76,6 +76,10 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { adminToken := randomHex(24) creatorID := newUUIDv4() now := nowUTC() + allowVoteChange := true + if input.AllowVoteChange != nil { + allowVoteChange = *input.AllowVoteChange + } settings := RoomSettings{ RoomName: roomName, @@ -84,6 +88,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { AllowSpectators: input.AllowSpectators, AnonymousVoting: input.AnonymousVoting, AutoReset: input.AutoReset, + AllowVoteChange: allowVoteChange, RevealMode: revealMode, VotingTimeoutSec: max(0, input.VotingTimeoutSec), } @@ -95,7 +100,8 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { } creator := &Participant{ - ID: creatorID, + ID: creatorID, + SessionToken: randomHex(24), Username: creatorUsername, Role: RoleParticipant, IsAdmin: true, @@ -139,6 +145,7 @@ func (m *Manager) CreateRoom(input CreateRoomInput) (CreateRoomResult, error) { result := CreateRoomResult{ RoomID: roomID, CreatorParticipantID: creatorID, + CreatorSessionToken: creator.SessionToken, AdminToken: adminToken, ParticipantLink: "/room/" + roomID, AdminLink: "/room/" + roomID + "?adminToken=" + adminToken, @@ -183,6 +190,9 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, if !ok { return JoinRoomResult{}, ErrParticipantNotFound } + if !secureTokenMatches(existing.SessionToken, input.SessionToken) { + return JoinRoomResult{}, ErrUnauthorized + } wasConnected := existing.Connected existing.Username = username @@ -203,6 +213,7 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: existing.ID, + SessionToken: existing.SessionToken, IsAdmin: existing.IsAdmin, Role: existing.Role, Username: existing.Username, @@ -226,7 +237,8 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, } participant := &Participant{ - ID: newUUIDv4(), + ID: newUUIDv4(), + SessionToken: randomHex(24), Username: username, Role: role, IsAdmin: isAdminByToken, @@ -247,13 +259,14 @@ func (m *Manager) JoinRoom(roomID string, input JoinRoomInput) (JoinRoomResult, go m.broadcastRoom(room.ID) return JoinRoomResult{ ParticipantID: participant.ID, + SessionToken: participant.SessionToken, IsAdmin: participant.IsAdmin, Role: participant.Role, Username: participant.Username, }, nil } -func (m *Manager) LeaveRoom(roomID, participantID string) error { +func (m *Manager) LeaveRoom(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err @@ -262,9 +275,9 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error { room.mu.Lock() defer room.mu.Unlock() - participant, ok := room.Participants[participantID] - if !ok { - return ErrParticipantNotFound + participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) + if err != nil { + return err } if !participant.Connected { @@ -281,7 +294,7 @@ func (m *Manager) LeaveRoom(roomID, participantID string) error { return nil } -func (m *Manager) CastVote(roomID, participantID, card string) error { +func (m *Manager) CastVote(roomID, participantID, sessionToken, card string) error { room, err := m.getRoom(roomID) if err != nil { return err @@ -290,9 +303,9 @@ func (m *Manager) CastVote(roomID, participantID, card string) error { room.mu.Lock() defer room.mu.Unlock() - participant, ok := room.Participants[participantID] - if !ok { - return ErrParticipantNotFound + participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) + if err != nil { + return err } if participant.Role != RoleParticipant { return ErrUnauthorized @@ -303,19 +316,26 @@ func (m *Manager) CastVote(roomID, participantID, card string) error { return ErrInvalidCard } - if room.Round.Revealed { - if room.Settings.AutoReset { - m.resetVotesLocked(room) - } else { - return ErrUnauthorized + if participant.HasVoted { + if participant.VoteValue == normalizedCard { + return nil + } + if !room.Settings.AllowVoteChange { + return ErrVoteChangeLocked } } + previousVote := participant.VoteValue + hadVoted := participant.HasVoted participant.HasVoted = true participant.VoteValue = normalizedCard participant.UpdatedAt = nowUTC() room.UpdatedAt = nowUTC() - m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard) + if hadVoted { + m.appendActivityLogLocked(room, "%s changed vote from %s to %s.", participant.Username, previousVote, normalizedCard) + } else { + m.appendActivityLogLocked(room, "%s voted %s.", participant.Username, normalizedCard) + } if room.Settings.RevealMode == RevealModeAutoAll && allActiveParticipantsVoted(room) { room.Round.Revealed = true @@ -330,7 +350,7 @@ func (m *Manager) CastVote(roomID, participantID, card string) error { return nil } -func (m *Manager) RevealVotes(roomID, participantID string) error { +func (m *Manager) RevealVotes(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err @@ -339,9 +359,9 @@ func (m *Manager) RevealVotes(roomID, participantID string) error { room.mu.Lock() defer room.mu.Unlock() - participant, ok := room.Participants[participantID] - if !ok { - return ErrParticipantNotFound + participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) + if err != nil { + return err } if !participant.IsAdmin { return ErrUnauthorized @@ -359,7 +379,7 @@ func (m *Manager) RevealVotes(roomID, participantID string) error { return nil } -func (m *Manager) ResetVotes(roomID, participantID string) error { +func (m *Manager) ResetVotes(roomID, participantID, sessionToken string) error { room, err := m.getRoom(roomID) if err != nil { return err @@ -368,9 +388,9 @@ func (m *Manager) ResetVotes(roomID, participantID string) error { room.mu.Lock() defer room.mu.Unlock() - participant, ok := room.Participants[participantID] - if !ok { - return ErrParticipantNotFound + participant, err := m.authorizeParticipantLocked(room, participantID, sessionToken) + if err != nil { + return err } if !participant.IsAdmin { return ErrUnauthorized @@ -388,17 +408,17 @@ func (m *Manager) ResetVotes(roomID, participantID string) error { return nil } -func (m *Manager) Subscribe(roomID, participantID string) (<-chan []byte, []byte, func(), error) { +func (m *Manager) Subscribe(roomID, participantID, sessionToken string) (<-chan []byte, []byte, func(), error) { room, err := m.getRoom(roomID) if err != nil { return nil, nil, nil, err } room.mu.Lock() - participant, ok := room.Participants[participantID] - if !ok { + participant, authErr := m.authorizeParticipantLocked(room, participantID, sessionToken) + if authErr != nil { room.mu.Unlock() - return nil, nil, nil, ErrParticipantNotFound + return nil, nil, nil, authErr } participant.Connected = true @@ -466,6 +486,17 @@ func (m *Manager) getRoom(roomID string) (*Room, error) { return room, nil } +func (m *Manager) authorizeParticipantLocked(room *Room, participantID, sessionToken string) (*Participant, error) { + participant, ok := room.Participants[participantID] + if !ok { + return nil, ErrParticipantNotFound + } + if !secureTokenMatches(participant.SessionToken, sessionToken) { + return nil, ErrUnauthorized + } + return participant, nil +} + func (m *Manager) loadFromDisk() error { persistedRooms, err := m.store.LoadAll() if err != nil { @@ -473,20 +504,52 @@ func (m *Manager) loadFromDisk() error { } for _, persisted := range persistedRooms { + allowVoteChange := true + if persisted.Settings.AllowVoteChange != nil { + allowVoteChange = *persisted.Settings.AllowVoteChange + } + settings := RoomSettings{ + RoomName: persisted.Settings.RoomName, + MaxPeople: persisted.Settings.MaxPeople, + Cards: append([]string(nil), persisted.Settings.Cards...), + AllowSpectators: persisted.Settings.AllowSpectators, + AnonymousVoting: persisted.Settings.AnonymousVoting, + AutoReset: persisted.Settings.AutoReset, + AllowVoteChange: allowVoteChange, + RevealMode: persisted.Settings.RevealMode, + VotingTimeoutSec: persisted.Settings.VotingTimeoutSec, + PasswordSalt: persisted.Settings.PasswordSalt, + PasswordHash: persisted.Settings.PasswordHash, + } + room := &Room{ ID: persisted.ID, AdminToken: persisted.AdminToken, CreatedAt: persisted.CreatedAt, UpdatedAt: persisted.UpdatedAt, - Settings: persisted.Settings, + Settings: settings, Round: persisted.Round, Participants: make(map[string]*Participant, len(persisted.Participants)), ActivityLog: append([]ActivityLogEntry(nil), persisted.ActivityLog...), subscribers: map[string]*subscriber{}, } for _, participant := range persisted.Participants { - participant.Connected = false - room.Participants[participant.ID] = participant + sessionToken := participant.SessionToken + if sessionToken == "" { + sessionToken = randomHex(24) + } + room.Participants[participant.ID] = &Participant{ + ID: participant.ID, + SessionToken: sessionToken, + Username: participant.Username, + Role: participant.Role, + IsAdmin: participant.IsAdmin, + Connected: false, + HasVoted: participant.HasVoted, + VoteValue: participant.VoteValue, + JoinedAt: participant.JoinedAt, + UpdatedAt: participant.UpdatedAt, + } } m.rooms[room.ID] = room @@ -496,10 +559,21 @@ func (m *Manager) loadFromDisk() error { } func (room *Room) toPersisted() persistedRoom { - participants := make([]*Participant, 0, len(room.Participants)) + allowVoteChange := room.Settings.AllowVoteChange + participants := make([]*persistedParticipant, 0, len(room.Participants)) for _, participant := range sortParticipants(room.Participants) { - clone := *participant - participants = append(participants, &clone) + participants = append(participants, &persistedParticipant{ + ID: participant.ID, + SessionToken: participant.SessionToken, + Username: participant.Username, + Role: participant.Role, + IsAdmin: participant.IsAdmin, + Connected: participant.Connected, + HasVoted: participant.HasVoted, + VoteValue: participant.VoteValue, + JoinedAt: participant.JoinedAt, + UpdatedAt: participant.UpdatedAt, + }) } return persistedRoom{ @@ -507,7 +581,19 @@ func (room *Room) toPersisted() persistedRoom { AdminToken: room.AdminToken, CreatedAt: room.CreatedAt, UpdatedAt: room.UpdatedAt, - Settings: room.Settings, + Settings: persistedRoomSettings{ + RoomName: room.Settings.RoomName, + MaxPeople: room.Settings.MaxPeople, + Cards: append([]string(nil), room.Settings.Cards...), + AllowSpectators: room.Settings.AllowSpectators, + AnonymousVoting: room.Settings.AnonymousVoting, + AutoReset: room.Settings.AutoReset, + AllowVoteChange: &allowVoteChange, + RevealMode: room.Settings.RevealMode, + VotingTimeoutSec: room.Settings.VotingTimeoutSec, + PasswordSalt: room.Settings.PasswordSalt, + PasswordHash: room.Settings.PasswordHash, + }, Round: room.Round, Participants: participants, ActivityLog: append([]ActivityLogEntry(nil), room.ActivityLog...), @@ -583,6 +669,7 @@ func (m *Manager) marshalRoomState(room *Room, viewerParticipantID string) ([]by AllowSpectators: room.Settings.AllowSpectators, AnonymousVoting: room.Settings.AnonymousVoting, AutoReset: room.Settings.AutoReset, + AllowVoteChange: room.Settings.AllowVoteChange, VotingTimeoutSec: room.Settings.VotingTimeoutSec, Participants: participants, SelfParticipantID: viewerParticipantID, diff --git a/src/state/types.go b/src/state/types.go index 7514807..b1f1074 100644 --- a/src/state/types.go +++ b/src/state/types.go @@ -23,6 +23,7 @@ var ( ErrSpectatorsBlocked = errors.New("spectators are not allowed") ErrPasswordRequired = errors.New("password required or invalid") ErrInvalidCard = errors.New("invalid card") + ErrVoteChangeLocked = errors.New("vote changes are disabled for this room") ) type RoomSettings struct { @@ -32,22 +33,51 @@ type RoomSettings struct { AllowSpectators bool `json:"allowSpectators"` AnonymousVoting bool `json:"anonymousVoting"` AutoReset bool `json:"autoReset"` + AllowVoteChange bool `json:"allowVoteChange"` RevealMode string `json:"revealMode"` VotingTimeoutSec int `json:"votingTimeoutSec"` PasswordSalt string `json:"passwordSalt,omitempty"` PasswordHash string `json:"passwordHash,omitempty"` } +type persistedRoomSettings struct { + RoomName string `json:"roomName"` + MaxPeople int `json:"maxPeople"` + Cards []string `json:"cards"` + AllowSpectators bool `json:"allowSpectators"` + AnonymousVoting bool `json:"anonymousVoting"` + AutoReset bool `json:"autoReset"` + AllowVoteChange *bool `json:"allowVoteChange,omitempty"` + RevealMode string `json:"revealMode"` + VotingTimeoutSec int `json:"votingTimeoutSec"` + PasswordSalt string `json:"passwordSalt,omitempty"` + PasswordHash string `json:"passwordHash,omitempty"` +} + type Participant struct { - ID string `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - IsAdmin bool `json:"isAdmin"` - Connected bool `json:"connected"` - HasVoted bool `json:"hasVoted"` - VoteValue string `json:"voteValue,omitempty"` - JoinedAt time.Time `json:"joinedAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + SessionToken string `json:"-"` + Username string `json:"username"` + Role string `json:"role"` + IsAdmin bool `json:"isAdmin"` + Connected bool `json:"connected"` + HasVoted bool `json:"hasVoted"` + VoteValue string `json:"voteValue,omitempty"` + JoinedAt time.Time `json:"joinedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type persistedParticipant struct { + ID string `json:"id"` + SessionToken string `json:"sessionToken,omitempty"` + Username string `json:"username"` + Role string `json:"role"` + IsAdmin bool `json:"isAdmin"` + Connected bool `json:"connected"` + HasVoted bool `json:"hasVoted"` + VoteValue string `json:"voteValue,omitempty"` + JoinedAt time.Time `json:"joinedAt"` + UpdatedAt time.Time `json:"updatedAt"` } type RoundState struct { @@ -64,9 +94,9 @@ type persistedRoom struct { AdminToken string `json:"adminToken"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` - Settings RoomSettings `json:"settings"` + Settings persistedRoomSettings `json:"settings"` Round RoundState `json:"round"` - Participants []*Participant `json:"participants"` + Participants []*persistedParticipant `json:"participants"` ActivityLog []ActivityLogEntry `json:"activityLog,omitempty"` } @@ -97,6 +127,7 @@ type CreateRoomInput struct { AllowSpectators bool AnonymousVoting bool AutoReset bool + AllowVoteChange *bool RevealMode string VotingTimeoutSec int Password string @@ -104,6 +135,7 @@ type CreateRoomInput struct { type JoinRoomInput struct { ParticipantID string + SessionToken string Username string Role string Password string @@ -113,6 +145,7 @@ type JoinRoomInput struct { type CreateRoomResult struct { RoomID string `json:"roomId"` CreatorParticipantID string `json:"creatorParticipantId"` + CreatorSessionToken string `json:"creatorSessionToken"` AdminToken string `json:"adminToken"` ParticipantLink string `json:"participantLink"` AdminLink string `json:"adminLink"` @@ -120,6 +153,7 @@ type CreateRoomResult struct { type JoinRoomResult struct { ParticipantID string `json:"participantId"` + SessionToken string `json:"sessionToken"` IsAdmin bool `json:"isAdmin"` Role string `json:"role"` Username string `json:"username"` @@ -155,6 +189,7 @@ type PublicRoomState struct { AllowSpectators bool `json:"allowSpectators"` AnonymousVoting bool `json:"anonymousVoting"` AutoReset bool `json:"autoReset"` + AllowVoteChange bool `json:"allowVoteChange"` VotingTimeoutSec int `json:"votingTimeoutSec"` Participants []PublicParticipant `json:"participants"` SelfParticipantID string `json:"selfParticipantId"` diff --git a/src/state/utils.go b/src/state/utils.go index abbf287..75cc9cf 100644 --- a/src/state/utils.go +++ b/src/state/utils.go @@ -62,6 +62,13 @@ func passwordMatches(password, salt, expectedHash string) bool { return subtle.ConstantTimeCompare([]byte(computed), []byte(expectedHash)) == 1 } +func secureTokenMatches(expected, provided string) bool { + if expected == "" || provided == "" || len(expected) != len(provided) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(provided)) == 1 +} + func nowUTC() time.Time { return time.Now().UTC() } diff --git a/src/templates/index.html b/src/templates/index.html index 420b020..b69c2b7 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -104,6 +104,10 @@ Auto-reset cards after each reveal + diff --git a/static/js/config.js b/static/js/config.js index e3ee38d..8a2f0aa 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -1,5 +1,6 @@ const USERNAME_KEY = 'scrumPoker.username'; const PRESETS_KEY = 'scrumPoker.deckPresets.v1'; +const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.'; const SCALE_PRESETS = { fibonacci: ['0', '1', '2', '3', '5', '8', '13', '21', '?'], @@ -502,6 +503,7 @@ roomConfigForm.addEventListener('submit', async (event) => { allowSpectators: Boolean(formData.get('allowSpectators')), anonymousVoting: Boolean(formData.get('anonymousVoting')), autoReset: Boolean(formData.get('autoReset')), + allowVoteChange: Boolean(formData.get('allowVoteChange')), revealMode: (formData.get('revealMode') || 'manual').toString(), votingTimeoutSec: Number(formData.get('votingTimeoutSec') || 0), password: (formData.get('password') || '').toString(), @@ -522,7 +524,12 @@ roomConfigForm.addEventListener('submit', async (event) => { return; } - const target = `/room/${encodeURIComponent(data.roomId)}?participantId=${encodeURIComponent(data.creatorParticipantId)}&adminToken=${encodeURIComponent(data.adminToken)}&username=${encodeURIComponent(payload.creatorUsername)}`; + localStorage.setItem(`${ROOM_SESSION_KEY_PREFIX}${data.roomId}`, JSON.stringify({ + participantId: data.creatorParticipantId, + sessionToken: data.creatorSessionToken, + })); + + const target = `/room/${encodeURIComponent(data.roomId)}?adminToken=${encodeURIComponent(data.adminToken)}&username=${encodeURIComponent(payload.creatorUsername)}`; window.location.assign(target); } catch (_err) { statusLine.textContent = 'Network error while creating room.'; diff --git a/static/js/room.js b/static/js/room.js index 2755243..de9de1c 100644 --- a/static/js/room.js +++ b/static/js/room.js @@ -1,4 +1,5 @@ const USERNAME_KEY = 'scrumPoker.username'; +const ROOM_SESSION_KEY_PREFIX = 'scrumPoker.roomSession.'; const roomID = document.body.dataset.roomId; const params = new URLSearchParams(window.location.search); @@ -32,6 +33,7 @@ const joinPasswordInput = document.getElementById('join-password'); const joinAdminTokenInput = document.getElementById('join-admin-token'); const joinError = document.getElementById('join-error'); let participantID = params.get('participantId') || ''; +let sessionToken = params.get('sessionToken') || ''; let adminToken = params.get('adminToken') || ''; const prefillUsername = params.get('username') || ''; let eventSource = null; @@ -43,6 +45,45 @@ const savedUsername = localStorage.getItem(USERNAME_KEY) || ''; joinUsernameInput.value = prefillUsername || savedUsername; joinAdminTokenInput.value = adminToken; +function roomSessionStorageKey() { + return `${ROOM_SESSION_KEY_PREFIX}${roomID}`; +} + +function persistRoomSession() { + if (!participantID || !sessionToken) { + localStorage.removeItem(roomSessionStorageKey()); + return; + } + + localStorage.setItem(roomSessionStorageKey(), JSON.stringify({ + participantId: participantID, + sessionToken, + })); +} + +function loadRoomSessionFromStorage() { + try { + const raw = localStorage.getItem(roomSessionStorageKey()); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + if (!participantID && typeof parsed.participantId === 'string') { + participantID = parsed.participantId; + } + if (!sessionToken && typeof parsed.sessionToken === 'string') { + sessionToken = parsed.sessionToken; + } + } catch (_err) { + localStorage.removeItem(roomSessionStorageKey()); + } +} + +if (!participantID || !sessionToken) { + loadRoomSessionFromStorage(); +} +persistRoomSession(); + if (!window.CardUI || typeof window.CardUI.appendFace !== 'function') { throw new Error('CardUI is not loaded. Ensure /static/js/cards.js is included before room.js.'); } @@ -62,12 +103,8 @@ function setJoinError(message) { function updateURL() { const next = new URL(window.location.href); next.searchParams.delete('username'); - - if (participantID) { - next.searchParams.set('participantId', participantID); - } else { - next.searchParams.delete('participantId'); - } + next.searchParams.delete('participantId'); + next.searchParams.delete('sessionToken'); if (adminToken) { next.searchParams.set('adminToken', adminToken); @@ -90,11 +127,15 @@ function setRoomMessage(message) { } async function joinRoom({ username, role, password, participantIdOverride }) { + const activeParticipantID = participantIdOverride || participantID; + const rejoinParticipantID = activeParticipantID && sessionToken ? activeParticipantID : ''; + const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - participantId: participantIdOverride || participantID, + participantId: rejoinParticipantID, + sessionToken, username, role, password, @@ -108,7 +149,9 @@ async function joinRoom({ username, role, password, participantIdOverride }) { } participantID = data.participantId; + sessionToken = data.sessionToken; localStorage.setItem(USERNAME_KEY, data.username); + persistRoomSession(); updateURL(); setJoinError(''); return data; @@ -235,9 +278,9 @@ function renderSummary(state) { summaryRecommended.textContent = recommended === null ? 'Recommended: -' : `Recommended: ${recommended}`; } -function renderCards(cards, participants, isRevealed) { +function renderCards(cards, participants, isRevealed, allowVoteChange) { const self = participants.find((participant) => participant.id === participantID && participant.connected); - const canVote = self && self.role === 'participant'; + const canVote = self && self.role === 'participant' && (allowVoteChange || !self.hasVoted); const selfVote = self ? self.voteValue : ''; votingBoard.innerHTML = ''; @@ -319,7 +362,8 @@ function renderState(state) { roundStateLabel.textContent = state.revealed ? 'Cards revealed' : 'Cards hidden'; renderParticipants(state.participants, state.revealed); - renderCards(state.cards, state.participants, state.revealed); + const allowVoteChange = state.allowVoteChange !== false; + renderCards(state.cards, state.participants, state.revealed, allowVoteChange); renderSummary(state); const self = state.participants.find((participant) => participant.id === participantID && participant.connected); @@ -361,7 +405,7 @@ function connectSSE() { eventSource.close(); } - eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}`); + eventSource = new EventSource(`/api/rooms/${encodeURIComponent(roomID)}/events?participantId=${encodeURIComponent(participantID)}&sessionToken=${encodeURIComponent(sessionToken)}`); eventSource.addEventListener('state', (event) => { try { const payload = JSON.parse(event.data); @@ -379,7 +423,7 @@ function connectSSE() { } async function castVote(card) { - if (!participantID) { + if (!participantID || !sessionToken) { return; } @@ -387,7 +431,7 @@ async function castVote(card) { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participantId: participantID, card }), + body: JSON.stringify({ participantId: participantID, sessionToken, card }), }); if (!response.ok) { @@ -400,7 +444,7 @@ async function castVote(card) { } async function adminAction(action) { - if (!participantID) { + if (!participantID || !sessionToken) { return; } @@ -408,7 +452,7 @@ async function adminAction(action) { const response = await fetch(`/api/rooms/${encodeURIComponent(roomID)}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ participantId: participantID }), + body: JSON.stringify({ participantId: participantID, sessionToken }), }); if (!response.ok) { @@ -421,7 +465,7 @@ async function adminAction(action) { } async function changeName() { - if (!participantID) { + if (!participantID || !sessionToken) { return; } @@ -486,21 +530,18 @@ joinForm.addEventListener('submit', async (event) => { adminToken = joinAdminTokenInput.value.trim(); try { - const result = await joinRoom({ + await joinRoom({ username, role: joinRoleInput.value, password: joinPasswordInput.value, participantIdOverride: participantID, }); - if (result.isAdmin) { - const adminRoomURL = `/room/${encodeURIComponent(roomID)}?participantId=${encodeURIComponent(participantID)}&adminToken=${encodeURIComponent(adminToken)}`; - window.location.assign(adminRoomURL); - return; - } connectSSE(); } catch (err) { - if (participantID) { + if (participantID || sessionToken) { participantID = ''; + sessionToken = ''; + persistRoomSession(); updateURL(); } setJoinError(err.message); @@ -508,7 +549,7 @@ joinForm.addEventListener('submit', async (event) => { }); async function tryAutoJoinExistingParticipant() { - if (!participantID) { + if (!participantID || !sessionToken) { return; } @@ -524,16 +565,18 @@ async function tryAutoJoinExistingParticipant() { connectSSE(); } catch (_err) { participantID = ''; + sessionToken = ''; + persistRoomSession(); updateURL(); } } window.addEventListener('pagehide', () => { - if (!participantID) { + if (!participantID || !sessionToken) { return; } - const payload = JSON.stringify({ participantId: participantID }); + const payload = JSON.stringify({ participantId: participantID, sessionToken }); navigator.sendBeacon(`/api/rooms/${encodeURIComponent(roomID)}/leave`, new Blob([payload], { type: 'application/json' })); });