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.
This commit is contained in:
Alex P 2025-10-08 21:37:02 +03:00
parent b0494e8eef
commit a1548fe5b1
14 changed files with 237 additions and 80 deletions

View File

@ -157,10 +157,11 @@ var defaultConfig = &Config{
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes
SessionSettings: &SessionSettings{ SessionSettings: &SessionSettings{
RequireApproval: false, RequireApproval: false,
RequireNickname: false, RequireNickname: false,
ReconnectGrace: 10, // 10 seconds default ReconnectGrace: 10,
PrivateKeystrokes: false, // By default, share keystrokes with observers PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
}, },
JigglerEnabled: false, JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI // This is the "Standard" jiggler option in the UI

View File

@ -210,7 +210,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{ writeJSONRPCEvent("sessionAccessDenied", map[string]interface{}{
"message": "Access denied by primary session", "message": "Access denied by primary session",
}, targetSession) }, targetSession)
sessionManager.RemoveSession(sessionID) sessionManager.broadcastSessionListUpdate()
result = map[string]interface{}{"status": "denied"} result = map[string]interface{}{"status": "denied"}
} else { } else {
handlerErr = errors.New("session not found or not pending") handlerErr = errors.New("session not found or not pending")
@ -218,6 +218,26 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
} else { } else {
handlerErr = errors.New("invalid sessionId parameter") 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": case "updateSessionNickname":
sessionID, _ := request.Params["sessionId"].(string) sessionID, _ := request.Params["sessionId"].(string)
nickname, _ := request.Params["nickname"].(string) nickname, _ := request.Params["nickname"].(string)

View File

@ -19,10 +19,11 @@ func Main() {
// Initialize currentSessionSettings to use config's persistent SessionSettings // Initialize currentSessionSettings to use config's persistent SessionSettings
if config.SessionSettings == nil { if config.SessionSettings == nil {
config.SessionSettings = &SessionSettings{ config.SessionSettings = &SessionSettings{
RequireApproval: false, RequireApproval: false,
RequireNickname: false, RequireNickname: false,
ReconnectGrace: 10, ReconnectGrace: 10,
PrivateKeystrokes: false, PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
} }
_ = SaveConfig() _ = SaveConfig()
} }

View File

@ -147,16 +147,17 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
// Check if this session ID is within grace period for reconnection
wasWithinGracePeriod := false wasWithinGracePeriod := false
wasPreviouslyPrimary := false wasPreviouslyPrimary := false
wasPreviouslyPending := false
if graceTime, exists := sm.reconnectGrace[session.ID]; exists { if graceTime, exists := sm.reconnectGrace[session.ID]; exists {
if time.Now().Before(graceTime) { if time.Now().Before(graceTime) {
wasWithinGracePeriod = true wasWithinGracePeriod = true
// Check if this was specifically the primary
wasPreviouslyPrimary = (sm.lastPrimaryID == session.ID) 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) delete(sm.reconnectGrace, session.ID)
} }
@ -265,6 +266,7 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
Int("totalSessions", len(sm.sessions)). Int("totalSessions", len(sm.sessions)).
Bool("wasWithinGracePeriod", wasWithinGracePeriod). Bool("wasWithinGracePeriod", wasWithinGracePeriod).
Bool("wasPreviouslyPrimary", wasPreviouslyPrimary). Bool("wasPreviouslyPrimary", wasPreviouslyPrimary).
Bool("wasPreviouslyPending", wasPreviouslyPending).
Bool("isBlacklisted", isBlacklisted). Bool("isBlacklisted", isBlacklisted).
Msg("AddSession state analysis") 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 // Reset HID availability to force re-handshake for input functionality
session.hidRPCAvailable = false session.hidRPCAvailable = false
} else { } else {
// Someone else became primary in the meantime, become observer
session.Mode = SessionModeObserver session.Mode = SessionModeObserver
} }
} else if wasPreviouslyPending {
session.Mode = SessionModePending
} else if globalSettings != nil && globalSettings.RequireApproval && primaryExists && !wasWithinGracePeriod { } 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 session.Mode = SessionModePending
// Notify primary about the pending session, but only if nickname is not required OR already provided // Notify primary about the pending session, but only if nickname is not required OR already provided
if primary := sm.sessions[sm.primarySessionID]; primary != nil { if primary := sm.sessions[sm.primarySessionID]; primary != nil {

View File

@ -116,5 +116,17 @@ export const sessionApi = {
} }
}); });
}); });
},
requestSessionApproval: async (sendFn: RpcSendFunction): Promise<void> => {
return new Promise((resolve, reject) => {
sendFn("requestSessionApproval", {}, (response: JsonRpcResponse) => {
if (response.error) {
reject(new Error(response.error.message));
} else {
resolve();
}
});
});
} }
}; };

View File

@ -4,7 +4,7 @@ import { XCircleIcon } from "@heroicons/react/24/outline";
import { DEVICE_API, CLOUD_API } from "@/ui.config"; import { DEVICE_API, CLOUD_API } from "@/ui.config";
import { isOnDevice } from "@/main"; import { isOnDevice } from "@/main";
import { useUserStore } from "@/hooks/stores"; import { useUserStore, useSettingsStore } from "@/hooks/stores";
import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore"; import { useSessionStore, useSharedSessionStore } from "@/stores/sessionStore";
import api from "@/api"; import api from "@/api";
@ -14,18 +14,22 @@ interface AccessDeniedOverlayProps {
show: boolean; show: boolean;
message?: string; message?: string;
onRetry?: () => void; onRetry?: () => void;
onRequestApproval?: () => void;
} }
export default function AccessDeniedOverlay({ export default function AccessDeniedOverlay({
show, show,
message = "Your session access was denied", message = "Your session access was denied",
onRetry onRetry,
onRequestApproval
}: AccessDeniedOverlayProps) { }: AccessDeniedOverlayProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const setUser = useUserStore(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const { clearSession } = useSessionStore(); const { clearSession, rejectionCount, incrementRejectionCount } = useSessionStore();
const { clearNickname } = useSharedSessionStore(); const { clearNickname } = useSharedSessionStore();
const { maxRejectionAttempts } = useSettingsStore();
const [countdown, setCountdown] = useState(10); const [countdown, setCountdown] = useState(10);
const [isRetrying, setIsRetrying] = useState(false);
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
try { try {
@ -48,11 +52,17 @@ export default function AccessDeniedOverlay({
useEffect(() => { useEffect(() => {
if (!show) return; if (!show) return;
const newCount = incrementRejectionCount();
if (newCount >= maxRejectionAttempts) {
const hideTimer = setTimeout(() => {}, 3000);
return () => clearTimeout(hideTimer);
}
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown(prev => { setCountdown(prev => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
// Auto-redirect with proper logout
handleLogout(); handleLogout();
return 0; return 0;
} }
@ -61,10 +71,14 @@ export default function AccessDeniedOverlay({
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [show, handleLogout]); }, [show, handleLogout, incrementRejectionCount, maxRejectionAttempts]);
if (!show) return null; if (!show) return null;
if (rejectionCount >= maxRejectionAttempts) {
return null;
}
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4"> <div className="max-w-md w-full mx-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl p-6 space-y-4">
@ -88,17 +102,41 @@ export default function AccessDeniedOverlay({
</p> </p>
</div> </div>
{rejectionCount < maxRejectionAttempts && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">
<strong>Attempt {rejectionCount} of {maxRejectionAttempts}:</strong> {rejectionCount === maxRejectionAttempts - 1
? "This is your last attempt. Further rejections will hide this dialog."
: `You have ${maxRejectionAttempts - rejectionCount} attempt${maxRejectionAttempts - rejectionCount === 1 ? '' : 's'} remaining.`
}
</p>
</div>
)}
<p className="text-center text-sm text-slate-500 dark:text-slate-400"> <p className="text-center text-sm text-slate-500 dark:text-slate-400">
Redirecting in <span className="font-mono font-bold">{countdown}</span> seconds... Redirecting in <span className="font-mono font-bold">{countdown}</span> seconds...
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
{onRetry && ( {(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
<Button <Button
onClick={onRetry} onClick={async () => {
if (isRetrying) return;
setIsRetrying(true);
try {
if (onRequestApproval) {
await onRequestApproval();
} else if (onRetry) {
await onRetry();
}
} finally {
setIsRetrying(false);
}
}}
theme="primary" theme="primary"
size="MD" size="MD"
text="Try Again" text={isRetrying ? "Requesting..." : "Request Access Again"}
disabled={isRetrying}
fullWidth fullWidth
/> />
)} )}

View File

@ -17,6 +17,7 @@ interface UnifiedSessionRequestDialogProps {
request: UnifiedSessionRequest | null; request: UnifiedSessionRequest | null;
onApprove: (id: string) => void | Promise<void>; onApprove: (id: string) => void | Promise<void>;
onDeny: (id: string) => void | Promise<void>; onDeny: (id: string) => void | Promise<void>;
onDismiss?: () => void;
onClose: () => void; onClose: () => void;
} }
@ -24,6 +25,7 @@ export default function UnifiedSessionRequestDialog({
request, request,
onApprove, onApprove,
onDeny, onDeny,
onDismiss,
onClose onClose
}: UnifiedSessionRequestDialogProps) { }: UnifiedSessionRequestDialogProps) {
const [timeRemaining, setTimeRemaining] = useState(0); const [timeRemaining, setTimeRemaining] = useState(0);
@ -200,43 +202,58 @@ export default function UnifiedSessionRequestDialog({
</div> </div>
)} )}
<div className="flex gap-3"> <div className="flex flex-col gap-2">
<Button <div className="flex gap-3">
onClick={async () => { <Button
if (isProcessing) return; onClick={async () => {
setIsProcessing(true); if (isProcessing) return;
try { setIsProcessing(true);
await onApprove(request.id); try {
await onApprove(request.id);
onClose();
} catch (error) {
console.error("Failed to approve request:", error);
setIsProcessing(false);
}
}}
theme="primary"
size="MD"
text="Approve"
fullWidth
disabled={isProcessing}
/>
<Button
onClick={async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
await onDeny(request.id);
onClose();
} catch (error) {
console.error("Failed to deny request:", error);
setIsProcessing(false);
}
}}
theme="light"
size="MD"
text="Deny"
fullWidth
disabled={isProcessing}
/>
</div>
{onDismiss && (
<Button
onClick={() => {
onDismiss();
onClose(); onClose();
} catch (error) { }}
console.error("Failed to approve request:", error); theme="light"
setIsProcessing(false); size="MD"
} text="Dismiss (Hide Request)"
}} fullWidth
theme="primary" disabled={isProcessing}
size="MD" />
text="Approve" )}
fullWidth
disabled={isProcessing}
/>
<Button
onClick={async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
await onDeny(request.id);
onClose();
} catch (error) {
console.error("Failed to deny request:", error);
setIsProcessing(false);
}
}}
theme="light"
size="MD"
text="Deny"
fullWidth
disabled={isProcessing}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -335,6 +335,9 @@ export interface SettingsState {
requireSessionApproval: boolean; requireSessionApproval: boolean;
setRequireSessionApproval: (required: boolean) => void; setRequireSessionApproval: (required: boolean) => void;
maxRejectionAttempts: number;
setMaxRejectionAttempts: (attempts: number) => void;
displayRotation: string; displayRotation: string;
setDisplayRotation: (rotation: string) => void; setDisplayRotation: (rotation: string) => void;
@ -381,6 +384,9 @@ export const useSettingsStore = create(
requireSessionApproval: true, requireSessionApproval: true,
setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }), setRequireSessionApproval: (required: boolean) => set({ requireSessionApproval: required }),
maxRejectionAttempts: 3,
setMaxRejectionAttempts: (attempts: number) => set({ maxRejectionAttempts: attempts }),
displayRotation: "270", displayRotation: "270",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),

View File

@ -69,12 +69,16 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
const previousMode = currentModeFromStore; const previousMode = currentModeFromStore;
updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending"); updateSessionMode(data.mode as "primary" | "observer" | "queued" | "pending");
// Clear requesting state when mode changes from queued
if (previousMode === "queued" && data.mode !== "queued") { if (previousMode === "queued" && data.mode !== "queued") {
const { setRequestingPrimary } = useSessionStore.getState(); const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false); 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 // HID re-initialization is now handled automatically by permission changes in usePermissions
// CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events // CRITICAL: Debounce notifications to prevent duplicates from rapid-fire events

View File

@ -147,15 +147,10 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
setSessionError("Your primary control request was denied"); setSessionError("Your primary control request was denied");
} }
// Handle session access denial (when your new session is denied)
if (method === "sessionAccessDenied") { if (method === "sessionAccessDenied") {
const { clearSession, setSessionError } = useSessionStore.getState(); const { setSessionError } = useSessionStore.getState();
const errorParams = params as { message?: string }; const errorParams = params as { message?: string };
setSessionError(errorParams.message || "Session access was denied by the primary session"); 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]); }, [handleSessionEvent, hasPermission, requireSessionApproval]);

View File

@ -21,7 +21,9 @@ export default function SessionsSettings() {
requireSessionNickname, requireSessionNickname,
setRequireSessionNickname, setRequireSessionNickname,
requireSessionApproval, requireSessionApproval,
setRequireSessionApproval setRequireSessionApproval,
maxRejectionAttempts,
setMaxRejectionAttempts
} = useSettingsStore(); } = useSettingsStore();
const [reconnectGrace, setReconnectGrace] = useState(10); const [reconnectGrace, setReconnectGrace] = useState(10);
@ -38,7 +40,8 @@ export default function SessionsSettings() {
requireNickname: boolean; requireNickname: boolean;
reconnectGrace?: number; reconnectGrace?: number;
primaryTimeout?: number; primaryTimeout?: number;
privateKeystrokes?: boolean privateKeystrokes?: boolean;
maxRejectionAttempts?: number;
}; };
setRequireSessionApproval(settings.requireApproval); setRequireSessionApproval(settings.requireApproval);
setRequireSessionNickname(settings.requireNickname); setRequireSessionNickname(settings.requireNickname);
@ -51,9 +54,12 @@ export default function SessionsSettings() {
if (settings.privateKeystrokes !== undefined) { if (settings.privateKeystrokes !== undefined) {
setPrivateKeystrokes(settings.privateKeystrokes); setPrivateKeystrokes(settings.privateKeystrokes);
} }
if (settings.maxRejectionAttempts !== undefined) {
setMaxRejectionAttempts(settings.maxRejectionAttempts);
}
} }
}); });
}, [send, setRequireSessionApproval, setRequireSessionNickname]); }, [send, setRequireSessionApproval, setRequireSessionNickname, setMaxRejectionAttempts]);
const updateSessionSettings = (updates: Partial<{ const updateSessionSettings = (updates: Partial<{
requireApproval: boolean; requireApproval: boolean;
@ -61,6 +67,7 @@ export default function SessionsSettings() {
reconnectGrace: number; reconnectGrace: number;
primaryTimeout: number; primaryTimeout: number;
privateKeystrokes: boolean; privateKeystrokes: boolean;
maxRejectionAttempts: number;
}>) => { }>) => {
if (!canModifySettings) { if (!canModifySettings) {
notify.error("Only the primary session can change this setting"); notify.error("Only the primary session can change this setting");
@ -74,6 +81,7 @@ export default function SessionsSettings() {
reconnectGrace: reconnectGrace, reconnectGrace: reconnectGrace,
primaryTimeout: primaryTimeout, primaryTimeout: primaryTimeout,
privateKeystrokes: privateKeystrokes, privateKeystrokes: privateKeystrokes,
maxRejectionAttempts: maxRejectionAttempts,
...updates ...updates
} }
}, (response: JsonRpcResponse) => { }, (response: JsonRpcResponse) => {
@ -149,6 +157,35 @@ export default function SessionsSettings() {
/> />
</SettingsItem> </SettingsItem>
<SettingsItem
title="Maximum Rejection Attempts"
description="Number of times a denied session can re-request approval before the modal is hidden"
>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="10"
value={maxRejectionAttempts}
disabled={!canModifySettings}
onChange={e => {
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"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">attempts</span>
</div>
</SettingsItem>
<SettingsItem <SettingsItem
title="Reconnect Grace Period" title="Reconnect Grace Period"
description="Time to wait for a session to reconnect before reassigning control" description="Time to wait for a session to reconnect before reassigning control"

View File

@ -1075,6 +1075,11 @@ export default function KvmIdRoute() {
? handleDenyPrimaryRequest ? handleDenyPrimaryRequest
: handleDenyNewSession : handleDenyNewSession
} }
onDismiss={
primaryControlRequest
? closePrimaryControlRequest
: closeNewSessionRequest
}
onClose={ onClose={
primaryControlRequest primaryControlRequest
? closePrimaryControlRequest ? closePrimaryControlRequest
@ -1086,10 +1091,14 @@ export default function KvmIdRoute() {
<AccessDeniedOverlay <AccessDeniedOverlay
show={accessDenied} show={accessDenied}
message="Your session access was denied by the primary session" message="Your session access was denied by the primary session"
onRetry={() => { onRequestApproval={async () => {
setAccessDenied(false); if (!send) return;
// Attempt to reconnect try {
window.location.reload(); await sessionApi.requestSessionApproval(send);
setAccessDenied(false);
} catch (error) {
console.error("Failed to re-request approval:", error);
}
}} }}
/> />

View File

@ -24,6 +24,7 @@ export interface SessionState {
// UI state // UI state
isRequestingPrimary: boolean; isRequestingPrimary: boolean;
sessionError: string | null; sessionError: string | null;
rejectionCount: number;
// Actions // Actions
setCurrentSession: (id: string, mode: SessionMode) => void; setCurrentSession: (id: string, mode: SessionMode) => void;
@ -32,6 +33,8 @@ export interface SessionState {
setSessionError: (error: string | null) => void; setSessionError: (error: string | null) => void;
updateSessionMode: (mode: SessionMode) => void; updateSessionMode: (mode: SessionMode) => void;
clearSession: () => void; clearSession: () => void;
incrementRejectionCount: () => number;
resetRejectionCount: () => void;
// Computed getters // Computed getters
isPrimary: () => boolean; isPrimary: () => boolean;
@ -52,6 +55,7 @@ export const useSessionStore = create<SessionState>()(
sessions: [], sessions: [],
isRequestingPrimary: false, isRequestingPrimary: false,
sessionError: null, sessionError: null,
rejectionCount: 0,
// Actions // Actions
setCurrentSession: (id: string, mode: SessionMode) => { setCurrentSession: (id: string, mode: SessionMode) => {
@ -84,10 +88,21 @@ export const useSessionStore = create<SessionState>()(
currentMode: null, currentMode: null,
sessions: [], sessions: [],
sessionError: null, 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 // Computed getters
isPrimary: () => { isPrimary: () => {
return get().currentMode === "primary"; return get().currentMode === "primary";

13
web.go
View File

@ -44,12 +44,13 @@ type WebRTCSessionRequest struct {
} }
type SessionSettings struct { type SessionSettings struct {
RequireApproval bool `json:"requireApproval"` RequireApproval bool `json:"requireApproval"`
RequireNickname bool `json:"requireNickname"` RequireNickname bool `json:"requireNickname"`
ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection ReconnectGrace int `json:"reconnectGrace,omitempty"` // Grace period in seconds for primary reconnection
PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session PrimaryTimeout int `json:"primaryTimeout,omitempty"` // Inactivity timeout in seconds for primary session
Nickname string `json:"nickname,omitempty"` Nickname string `json:"nickname,omitempty"`
PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events PrivateKeystrokes bool `json:"privateKeystrokes,omitempty"` // If true, only primary session sees keystroke events
MaxRejectionAttempts int `json:"maxRejectionAttempts,omitempty"`
} }
type SetPasswordRequest struct { type SetPasswordRequest struct {