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

@ -159,8 +159,9 @@ var defaultConfig = &Config{
SessionSettings: &SessionSettings{
RequireApproval: false,
RequireNickname: false,
ReconnectGrace: 10, // 10 seconds default
PrivateKeystrokes: false, // By default, share keystrokes with observers
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
},
JigglerEnabled: false,
// 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{}{
"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)

View File

@ -23,6 +23,7 @@ func Main() {
RequireNickname: false,
ReconnectGrace: 10,
PrivateKeystrokes: false,
MaxRejectionAttempts: 3,
}
_ = SaveConfig()
}

View File

@ -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 {

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 { 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 (
<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">
@ -88,17 +102,41 @@ export default function AccessDeniedOverlay({
</p>
</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">
Redirecting in <span className="font-mono font-bold">{countdown}</span> seconds...
</p>
<div className="flex gap-3">
{onRetry && (
{(onRequestApproval || onRetry) && rejectionCount < maxRejectionAttempts && (
<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"
size="MD"
text="Try Again"
text={isRetrying ? "Requesting..." : "Request Access Again"}
disabled={isRetrying}
fullWidth
/>
)}

View File

@ -17,6 +17,7 @@ interface UnifiedSessionRequestDialogProps {
request: UnifiedSessionRequest | null;
onApprove: (id: string) => void | Promise<void>;
onDeny: (id: string) => void | Promise<void>;
onDismiss?: () => void;
onClose: () => void;
}
@ -24,6 +25,7 @@ export default function UnifiedSessionRequestDialog({
request,
onApprove,
onDeny,
onDismiss,
onClose
}: UnifiedSessionRequestDialogProps) {
const [timeRemaining, setTimeRemaining] = useState(0);
@ -200,6 +202,7 @@ export default function UnifiedSessionRequestDialog({
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-3">
<Button
onClick={async () => {
@ -238,6 +241,20 @@ export default function UnifiedSessionRequestDialog({
disabled={isProcessing}
/>
</div>
{onDismiss && (
<Button
onClick={() => {
onDismiss();
onClose();
}}
theme="light"
size="MD"
text="Dismiss (Hide Request)"
fullWidth
disabled={isProcessing}
/>
)}
</div>
</div>
</div>
</div>

View File

@ -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 }),

View File

@ -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

View File

@ -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]);

View File

@ -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() {
/>
</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
title="Reconnect Grace Period"
description="Time to wait for a session to reconnect before reassigning control"

View File

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

View File

@ -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<SessionState>()(
sessions: [],
isRequestingPrimary: false,
sessionError: null,
rejectionCount: 0,
// Actions
setCurrentSession: (id: string, mode: SessionMode) => {
@ -84,10 +88,21 @@ export const useSessionStore = create<SessionState>()(
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";

1
web.go
View File

@ -50,6 +50,7 @@ type SessionSettings struct {
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 {