mirror of https://github.com/jetkvm/kvm.git
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:
parent
b0494e8eef
commit
a1548fe5b1
|
|
@ -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
|
||||||
|
|
|
||||||
22
jsonrpc.go
22
jsonrpc.go
|
|
@ -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)
|
||||||
|
|
|
||||||
9
main.go
9
main.go
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
13
web.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue