From a1548fe5b1d2009652e25a52ae23db84a3bddac5 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 8 Oct 2025 21:37:02 +0300 Subject: [PATCH] feat: improve session approval workflow with re-request and rejection limits Backend improvements: - Keep denied sessions alive in pending mode instead of removing them - Add requestSessionApproval RPC method for re-requesting access - Fix security issue: preserve pending mode on reconnection for denied sessions - Add MaxRejectionAttempts field to SessionSettings (default: 3, configurable 1-10) Frontend improvements: - Change "Try Again" button to "Request Access Again" that re-requests approval - Add rejection counter with configurable maximum attempts - Hide modal after max rejections; session stays pending in SessionPopover - Add "Dismiss" button for primary to hide approval requests without deciding - Add MaxRejectionAttempts control in multi-session settings page - Reset rejection count when session is approved This improves the user experience by allowing denied users to retry without page reloads, while preventing spam with configurable rejection limits. --- config.go | 9 +- jsonrpc.go | 22 ++++- main.go | 9 +- session_manager.go | 13 +-- ui/src/api/sessionApi.ts | 12 +++ ui/src/components/AccessDeniedOverlay.tsx | 54 +++++++++-- .../UnifiedSessionRequestDialog.tsx | 89 +++++++++++-------- ui/src/hooks/stores.ts | 6 ++ ui/src/hooks/useSessionEvents.ts | 6 +- ui/src/hooks/useSessionManagement.ts | 7 +- .../devices.$id.settings.multi-session.tsx | 43 ++++++++- ui/src/routes/devices.$id.tsx | 17 +++- ui/src/stores/sessionStore.ts | 17 +++- web.go | 13 +-- 14 files changed, 237 insertions(+), 80 deletions(-) diff --git a/config.go b/config.go index 024a5844..7347e1c3 100644 --- a/config.go +++ b/config.go @@ -157,10 +157,11 @@ var defaultConfig = &Config{ DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes SessionSettings: &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, // 10 seconds default - PrivateKeystrokes: false, // By default, share keystrokes with observers + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, }, JigglerEnabled: false, // This is the "Standard" jiggler option in the UI diff --git a/jsonrpc.go b/jsonrpc.go index 423c71f6..57d41db2 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -210,7 +210,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{ "message": "Access denied by primary session", }, targetSession) - sessionManager.RemoveSession(sessionID) + sessionManager.broadcastSessionListUpdate() result = map[string]interface{}{"status": "denied"} } else { handlerErr = errors.New("session not found or not pending") @@ -218,6 +218,26 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { } else { handlerErr = errors.New("invalid sessionId parameter") } + case "requestSessionApproval": + if session.Mode != SessionModePending { + handlerErr = errors.New("only pending sessions can request approval") + } else if currentSessionSettings != nil && currentSessionSettings.RequireApproval { + if primary := sessionManager.GetPrimarySession(); primary != nil { + go func() { + writeJSONRPCEvent("newSessionPending", map[string]interface{}{ + "sessionId": session.ID, + "source": session.Source, + "identity": session.Identity, + "nickname": session.Nickname, + }, primary) + }() + result = map[string]interface{}{"status": "requested"} + } else { + handlerErr = errors.New("no primary session available") + } + } else { + handlerErr = errors.New("session approval not required") + } case "updateSessionNickname": sessionID, _ := request.Params["sessionId"].(string) nickname, _ := request.Params["nickname"].(string) diff --git a/main.go b/main.go index b59b83a4..276d30a3 100644 --- a/main.go +++ b/main.go @@ -19,10 +19,11 @@ func Main() { // Initialize currentSessionSettings to use config's persistent SessionSettings if config.SessionSettings == nil { config.SessionSettings = &SessionSettings{ - RequireApproval: false, - RequireNickname: false, - ReconnectGrace: 10, - PrivateKeystrokes: false, + RequireApproval: false, + RequireNickname: false, + ReconnectGrace: 10, + PrivateKeystrokes: false, + MaxRejectionAttempts: 3, } _ = SaveConfig() } diff --git a/session_manager.go b/session_manager.go index 6dc388e0..42455385 100644 --- a/session_manager.go +++ b/session_manager.go @@ -147,16 +147,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe sm.mu.Lock() defer sm.mu.Unlock() - // Check if this session ID is within grace period for reconnection wasWithinGracePeriod := false wasPreviouslyPrimary := false + wasPreviouslyPending := false if graceTime, exists := sm.reconnectGrace[session.ID]; exists { if time.Now().Before(graceTime) { wasWithinGracePeriod = true - // Check if this was specifically the primary wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) + if reconnectInfo, hasInfo := sm.reconnectInfo[session.ID]; hasInfo { + wasPreviouslyPending = (reconnectInfo.Mode == SessionModePending) + } } - // Clean up grace period entry delete(sm.reconnectGrace, session.ID) } @@ -265,6 +266,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe Int("totalSessions", len(sm.sessions)). Bool("wasWithinGracePeriod", wasWithinGracePeriod). Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). + Bool("wasPreviouslyPending", wasPreviouslyPending). Bool("isBlacklisted", isBlacklisted). Msg("AddSession state analysis") @@ -313,12 +315,11 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe // Reset HID availability to force re-handshake for input functionality session.hidRPCAvailable = false } else { - // Someone else became primary in the meantime, become observer session.Mode = SessionModeObserver } + } else if wasPreviouslyPending { + session.Mode = SessionModePending } else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { - // New session requires approval from primary (but only if there IS a primary to approve) - // Skip approval for sessions reconnecting within grace period session.Mode = SessionModePending // Notify primary about the pending session, but only if nickname is not required OR already provided if primary := sm.sessions[sm.primarySessionID]; primary != nil { diff --git a/ui/src/api/sessionApi.ts b/ui/src/api/sessionApi.ts index 0cca4634..b6602fe4 100644 --- a/ui/src/api/sessionApi.ts +++ b/ui/src/api/sessionApi.ts @@ -116,5 +116,17 @@ export const sessionApi = { } }); }); + }, + + requestSessionApproval: async (sendFn: RpcSendFunction): Promise => { + return new Promise((resolve, reject) => { + sendFn("requestSessionApproval", {}, (response: JsonRpcResponse) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(); + } + }); + }); } }; \ No newline at end of file diff --git a/ui/src/components/AccessDeniedOverlay.tsx b/ui/src/components/AccessDeniedOverlay.tsx index 484be9b0..f9aa6aa5 100644 --- a/ui/src/components/AccessDeniedOverlay.tsx +++ b/ui/src/components/AccessDeniedOverlay.tsx @@ -4,7 +4,7 @@ import { XCircleIcon } from "@heroicons/react/24/outline"; import { DEVICE_API, CLOUD_API } from "@/ui.config"; import { isOnDevice } from "@/main"; -import { useUserStore } from "@/hooks/stores"; +import { useUserStore, useSettingsStore } from "@/hooks/stores"; import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; import api from "@/api"; @@ -14,18 +14,22 @@ interface AccessDeniedOverlayProps { show: boolean; message?: string; onRetry?: () => void; + onRequestApproval?: () => void; } export default function AccessDeniedOverlay({ show, message = "Your session access was denied", - onRetry + onRetry, + onRequestApproval }: AccessDeniedOverlayProps) { const navigate = useNavigate(); const setUser = useUserStore(state => state.setUser); - const { clearSession } = useSessionStore(); + const { clearSession, rejectionCount, incrementRejectionCount } = useSessionStore(); const { clearNickname } = useSharedSessionStore(); + const { maxRejectionAttempts } = useSettingsStore(); const [countdown, setCountdown] = useState(10); + const [isRetrying, setIsRetrying] = useState(false); const handleLogout = useCallback(async () => { try { @@ -48,11 +52,17 @@ export default function AccessDeniedOverlay({ useEffect(() => { if (!show) return; + const newCount = incrementRejectionCount(); + + if (newCount >= maxRejectionAttempts) { + const hideTimer = setTimeout(() => {}, 3000); + return () => clearTimeout(hideTimer); + } + const timer = setInterval(() => { setCountdown(prev => { if (prev <= 1) { clearInterval(timer); - // Auto-redirect with proper logout handleLogout(); return 0; } @@ -61,10 +71,14 @@ export default function AccessDeniedOverlay({ }, 1000); return () => clearInterval(timer); - }, [show, handleLogout]); + }, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]); if (!show) return null; + if (rejectionCount >= maxRejectionAttempts) { + return null; + } + return (
@@ -88,17 +102,41 @@ export default function AccessDeniedOverlay({

+ {rejectionCount < maxRejectionAttempts && ( +
+

+ Attempt {rejectionCount} of {maxRejectionAttempts}: {rejectionCount === maxRejectionAttempts - 1 + ? "This is your last attempt. Further rejections will hide this dialog." + : `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.` + } +

+
+ )} +

Redirecting in {countdown} seconds...

- {onRetry && ( + {(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
)} -
-
+ {onDismiss && ( +
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 180fb985..a3a8f301 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -335,6 +335,9 @@ export interface SettingsState { requireSessionApproval: boolean; setRequireSessionApproval: (required: boolean) => void; + maxRejectionAttempts: number; + setMaxRejectionAttempts: (attempts: number) => void; + displayRotation: string; setDisplayRotation: (rotation: string) => void; @@ -381,6 +384,9 @@ export const useSettingsStore = create( requireSessionApproval: true, setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }), + maxRejectionAttempts: 3, + setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }), + displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), diff --git a/ui/src/hooks/useSessionEvents.ts b/ui/src/hooks/useSessionEvents.ts index b5c6804a..66a667de 100644 --- a/ui/src/hooks/useSessionEvents.ts +++ b/ui/src/hooks/useSessionEvents.ts @@ -69,12 +69,16 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) { const previousMode = currentModeFromStore; updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending"); - // Clear requesting state when mode changes from queued if (previousMode === "queued" && data.mode !== "queued") { const { setRequestingPrimary } = useSessionStore.getState(); setRequestingPrimary(false); } + if (previousMode === "pending" && data.mode === "observer") { + const { resetRejectionCount } = useSessionStore.getState(); + resetRejectionCount(); + } + // HID re-initialization is now handled automatically by permission changes in usePermissions // CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events diff --git a/ui/src/hooks/useSessionManagement.ts b/ui/src/hooks/useSessionManagement.ts index 4b688368..c35c3852 100644 --- a/ui/src/hooks/useSessionManagement.ts +++ b/ui/src/hooks/useSessionManagement.ts @@ -147,15 +147,10 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) { setSessionError("Your primary control request was denied"); } - // Handle session access denial (when your new session is denied) if (method === "sessionAccessDenied") { - const { clearSession, setSessionError } = useSessionStore.getState(); + const { setSessionError } = useSessionStore.getState(); const errorParams = params as { message?: string }; setSessionError(errorParams.message || "Session access was denied by the primary session"); - // Clear session data as we're being disconnected - setTimeout(() => { - clearSession(); - }, 3000); // Give user time to see the error } }, [handleSessionEvent, hasPermission, requireSessionApproval]); diff --git a/ui/src/routes/devices.$id.settings.multi-session.tsx b/ui/src/routes/devices.$id.settings.multi-session.tsx index 1c6970e6..2efafdac 100644 --- a/ui/src/routes/devices.$id.settings.multi-session.tsx +++ b/ui/src/routes/devices.$id.settings.multi-session.tsx @@ -21,7 +21,9 @@ export default function SessionsSettings() { requireSessionNickname, setRequireSessionNickname, requireSessionApproval, - setRequireSessionApproval + setRequireSessionApproval, + maxRejectionAttempts, + setMaxRejectionAttempts } = useSettingsStore(); const [reconnectGrace, setReconnectGrace] = useState(10); @@ -38,7 +40,8 @@ export default function SessionsSettings() { requireNickname: boolean; reconnectGrace?: number; primaryTimeout?: number; - privateKeystrokes?: boolean + privateKeystrokes?: boolean; + maxRejectionAttempts?: number; }; setRequireSessionApproval(settings.requireApproval); setRequireSessionNickname(settings.requireNickname); @@ -51,9 +54,12 @@ export default function SessionsSettings() { if (settings.privateKeystrokes !== undefined) { setPrivateKeystrokes(settings.privateKeystrokes); } + if (settings.maxRejectionAttempts !== undefined) { + setMaxRejectionAttempts(settings.maxRejectionAttempts); + } } }); - }, [send, setRequireSessionApproval, setRequireSessionNickname]); + }, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]); const updateSessionSettings = (updates: Partial<{ requireApproval: boolean; @@ -61,6 +67,7 @@ export default function SessionsSettings() { reconnectGrace: number; primaryTimeout: number; privateKeystrokes: boolean; + maxRejectionAttempts: number; }>) => { if (!canModifySettings) { notify.error("Only the primary session can change this setting"); @@ -74,6 +81,7 @@ export default function SessionsSettings() { reconnectGrace: reconnectGrace, primaryTimeout: primaryTimeout, privateKeystrokes: privateKeystrokes, + maxRejectionAttempts: maxRejectionAttempts, ...updates } }, (response: JsonRpcResponse) => { @@ -149,6 +157,35 @@ export default function SessionsSettings() { /> + +
+ { + const newValue = parseInt(e.target.value) || 3; + if (newValue < 1 || newValue > 10) { + notify.error("Maximum attempts must be between 1 and 10"); + return; + } + setMaxRejectionAttempts(newValue); + updateSessionSettings({ maxRejectionAttempts: newValue }); + notify.success( + `Denied sessions can now retry up to ${newValue} time${newValue === 1 ? '' : 's'}` + ); + }} + className="w-20 px-2 py-1.5 border rounded-md bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed text-sm" + /> + attempts +
+
+ { - setAccessDenied(false); - // Attempt to reconnect - window.location.reload(); + onRequestApproval={async () => { + if (!send) return; + try { + await sessionApi.requestSessionApproval(send); + setAccessDenied(false); + } catch (error) { + console.error("Failed to re-request approval:", error); + } }} /> diff --git a/ui/src/stores/sessionStore.ts b/ui/src/stores/sessionStore.ts index 3e7a57b9..eee8aa0a 100644 --- a/ui/src/stores/sessionStore.ts +++ b/ui/src/stores/sessionStore.ts @@ -24,6 +24,7 @@ export interface SessionState { // UI state isRequestingPrimary: boolean; sessionError: string | null; + rejectionCount: number; // Actions setCurrentSession: (id: string, mode: SessionMode) => void; @@ -32,6 +33,8 @@ export interface SessionState { setSessionError: (error: string | null) => void; updateSessionMode: (mode: SessionMode) => void; clearSession: () => void; + incrementRejectionCount: () => number; + resetRejectionCount: () => void; // Computed getters isPrimary: () => boolean; @@ -52,6 +55,7 @@ export const useSessionStore = create()( sessions: [], isRequestingPrimary: false, sessionError: null, + rejectionCount: 0, // Actions setCurrentSession: (id: string, mode: SessionMode) => { @@ -84,10 +88,21 @@ export const useSessionStore = create()( currentMode: null, sessions: [], sessionError: null, - isRequestingPrimary: false + isRequestingPrimary: false, + rejectionCount: 0 }); }, + incrementRejectionCount: () => { + const newCount = get().rejectionCount + 1; + set({ rejectionCount: newCount }); + return newCount; + }, + + resetRejectionCount: () => { + set({ rejectionCount: 0 }); + }, + // Computed getters isPrimary: () => { return get().currentMode === "primary"; diff --git a/web.go b/web.go index 1c66ec6d..ecd7641c 100644 --- a/web.go +++ b/web.go @@ -44,12 +44,13 @@ type WebRTCSessionRequest struct { } type SessionSettings struct { - RequireApproval bool `json:"requireApproval"` - RequireNickname bool `json:"requireNickname"` - ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection - PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session - Nickname string `json:"nickname,omitempty"` - PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events + RequireApproval bool `json:"requireApproval"` + RequireNickname bool `json:"requireNickname"` + ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection + PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session + Nickname string `json:"nickname,omitempty"` + PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events + MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"` } type SetPasswordRequest struct {