refactor: centralize permissions with context provider and remove redundant code

Improvements:
- Centralized permission state management in PermissionsProvider
  - Eliminates duplicate RPC calls across components
  - Single source of truth for permission state
  - Automatic HID re-initialization on permission changes
- Split exports into separate files for React Fast Refresh compliance
  - Created types/permissions.ts for Permission enum
  - Created hooks/usePermissions.ts for the hook with safe defaults
  - Created contexts/PermissionsContext.ts for context definition
  - Updated PermissionsProvider.tsx to only export the provider component
- Removed redundant getSessionSettings RPC call (settings already in WebSocket/WebRTC messages)
- Added connectionModeChanged event handler for seamless emergency promotions
- Fixed approval dialog race condition by checking isLoadingPermissions
- Removed all redundant comments and code for leaner implementation
- Updated imports across 10+ component files

Result: Zero ESLint warnings, cleaner architecture, no duplicate RPC calls, all functionality preserved
This commit is contained in:
Alex P 2025-10-11 00:11:20 +03:00
parent f9e190f8b9
commit 335c6ee35e
16 changed files with 2812 additions and 251 deletions

2592
multi-session.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,8 @@ import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import SessionPopover from "@/components/popovers/SessionPopover"; import SessionPopover from "@/components/popovers/SessionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,

View File

@ -6,7 +6,8 @@ import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores"; import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
export default function MacroBar() { export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();

View File

@ -8,7 +8,8 @@ import clsx from "clsx";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { sessionApi } from "@/api/sessionApi"; import { sessionApi } from "@/api/sessionApi";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;

View File

@ -2,7 +2,8 @@ import { PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import clsx from "clsx"; import clsx from "clsx";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
interface Session { interface Session {
id: string; id: string;

View File

@ -14,7 +14,8 @@ import {
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import useMouse from "@/hooks/useMouse"; import useMouse from "@/hooks/useMouse";
import { import {

View File

@ -0,0 +1,5 @@
import { createContext } from "react";
import { PermissionsContextValue } from "@/hooks/usePermissions";
export const PermissionsContext = createContext<PermissionsContextValue | undefined>(undefined);

View File

@ -1,171 +1,34 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useContext } from "react";
import { useJsonRpc, JsonRpcRequest } from "@/hooks/useJsonRpc"; import { PermissionsContext } from "@/contexts/PermissionsContext";
import { useSessionStore } from "@/stores/sessionStore"; import { Permission } from "@/types/permissions";
import { useRTCStore } from "@/hooks/stores";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; export interface PermissionsContextValue {
// Permission types matching backend
export enum Permission {
// Video/Display permissions
VIDEO_VIEW = "video.view",
// Input permissions
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
// Session management permissions
SESSION_TRANSFER = "session.transfer",
SESSION_APPROVE = "session.approve",
SESSION_KICK = "session.kick",
SESSION_REQUEST_PRIMARY = "session.request_primary",
SESSION_RELEASE_PRIMARY = "session.release_primary",
SESSION_MANAGE = "session.manage",
// Mount/Media permissions
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
// Extension permissions
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
// Settings permissions
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
// System permissions
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
// Power/USB control permissions
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
// Terminal/Serial permissions
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}
interface PermissionsResponse {
mode: string;
permissions: Record<string, boolean>; permissions: Record<string, boolean>;
isLoading: boolean;
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (...perms: Permission[]) => boolean;
hasAllPermissions: (...perms: Permission[]) => boolean;
isPrimary: () => boolean;
isObserver: () => boolean;
isPending: () => boolean;
} }
export function usePermissions() { export function usePermissions(): PermissionsContextValue {
const { currentMode } = useSessionStore(); const context = useContext(PermissionsContext);
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
// Function to poll permissions
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
setIsLoading(true);
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
// Handle connectionModeChanged events that require WebRTC reconnection
const handleRpcRequest = useCallback((request: JsonRpcRequest) => {
if (request.method === "connectionModeChanged") {
console.info("Connection mode changed, WebRTC reconnection required", request.params);
// For session promotion that requires reconnection, refresh the page
// This ensures WebRTC connection is re-established with proper mode
const params = request.params as { action?: string; reason?: string };
if (params.action === "reconnect_required" && params.reason === "session_promotion") {
console.info("Session promoted, refreshing page to re-establish WebRTC connection");
// Small delay to ensure all state updates are processed
setTimeout(() => {
window.location.reload();
}, 500);
}
}
}, []);
const { send } = useJsonRpc(handleRpcRequest);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
pollPermissions(send);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMode, rpcDataChannel?.readyState]);
// Monitor permission changes and re-initialize HID when gaining control
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
// If we just gained control permissions, re-initialize HID
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
// Reset protocol version to force re-handshake
setRpcHidProtocolVersion(null);
// Import handshake functionality dynamically
import("./hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
// Send handshake after a small delay
setTimeout(() => {
if (rpcHidChannel?.readyState === "open") {
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
try {
const data = handshakeMessage.marshal();
rpcHidChannel.send(data as unknown as ArrayBuffer);
console.info("Sent HID handshake after permission change");
} catch (e) {
console.error("Failed to send HID handshake", e);
}
}
}, 100);
});
}
previousCanControl.current = currentCanControl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]); // hasPermission depends on permissions which is already in deps
const hasPermission = (permission: Permission): boolean => {
return permissions[permission] === true;
};
const hasAnyPermission = (...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
};
const hasAllPermissions = (...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
};
// Session mode helpers
const isPrimary = () => currentMode === "primary";
const isObserver = () => currentMode === "observer";
const isPending = () => currentMode === "pending";
if (context === undefined) {
return { return {
permissions, permissions: {},
isLoading, isLoading: true,
hasPermission, hasPermission: () => false,
hasAnyPermission, hasAnyPermission: () => false,
hasAllPermissions, hasAllPermissions: () => false,
isPrimary, isPrimary: () => false,
isObserver, isObserver: () => false,
isPending, isPending: () => false,
}; };
} }
return context;
}

View File

@ -16,6 +16,10 @@ interface ModeChangedData {
mode: string; mode: string;
} }
interface ConnectionModeChangedData {
newMode: string;
}
export function useSessionEvents(sendFn: RpcSendFunction | null) { export function useSessionEvents(sendFn: RpcSendFunction | null) {
const { const {
currentMode, currentMode,
@ -27,7 +31,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
const sendFnRef = useRef(sendFn); const sendFnRef = useRef(sendFn);
sendFnRef.current = sendFn; sendFnRef.current = sendFn;
// Handle session-related RPC events
const handleSessionEvent = (method: string, params: unknown) => { const handleSessionEvent = (method: string, params: unknown) => {
switch (method) { switch (method) {
case "sessionsUpdated": case "sessionsUpdated":
@ -36,6 +39,9 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
case "modeChanged": case "modeChanged":
handleModeChanged(params as ModeChangedData); handleModeChanged(params as ModeChangedData);
break; break;
case "connectionModeChanged":
handleConnectionModeChanged(params as ConnectionModeChangedData);
break;
case "hidReadyForPrimary": case "hidReadyForPrimary":
handleHidReadyForPrimary(); handleHidReadyForPrimary();
break; break;
@ -103,23 +109,25 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
} }
}; };
const handleConnectionModeChanged = (data: ConnectionModeChangedData) => {
if (data.newMode) {
handleModeChanged({ mode: data.newMode });
}
};
const handleHidReadyForPrimary = () => { const handleHidReadyForPrimary = () => {
// Backend signals that HID system is ready for primary session re-initialization
const { rpcHidChannel } = useRTCStore.getState(); const { rpcHidChannel } = useRTCStore.getState();
if (rpcHidChannel?.readyState === "open") { if (rpcHidChannel?.readyState === "open") {
// Trigger HID re-handshake
rpcHidChannel.dispatchEvent(new Event("open")); rpcHidChannel.dispatchEvent(new Event("open"));
} }
}; };
const handleOtherSessionConnected = () => { const handleOtherSessionConnected = () => {
// Another session is trying to connect
notify.warning("Another session is connecting", { notify.warning("Another session is connecting", {
duration: 5000 duration: 5000
}); });
}; };
// Fetch initial sessions when component mounts
useEffect(() => { useEffect(() => {
if (!sendFnRef.current) return; if (!sendFnRef.current) return;
@ -136,7 +144,6 @@ export function useSessionEvents(sendFn: RpcSendFunction | null) {
fetchSessions(); fetchSessions();
}, [setSessions, setSessionError]); }, [setSessions, setSessionError]);
// Set up periodic session refresh
useEffect(() => { useEffect(() => {
if (!sendFnRef.current) return; if (!sendFnRef.current) return;

View File

@ -3,7 +3,8 @@ import { useEffect, useCallback, useState } from "react";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { useSessionEvents } from "@/hooks/useSessionEvents"; import { useSessionEvents } from "@/hooks/useSessionEvents";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void; type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
@ -32,21 +33,19 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
clearSession clearSession
} = useSessionStore(); } = useSessionStore();
const { hasPermission } = usePermissions(); const { hasPermission, isLoading: isLoadingPermissions } = usePermissions();
const { requireSessionApproval } = useSettingsStore(); const { requireSessionApproval } = useSettingsStore();
const { handleSessionEvent } = useSessionEvents(sendFn); const { handleSessionEvent } = useSessionEvents(sendFn);
const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null); const [primaryControlRequest, setPrimaryControlRequest] = useState<PrimaryControlRequest | null>(null);
const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null); const [newSessionRequest, setNewSessionRequest] = useState<NewSessionRequest | null>(null);
// Handle session info from WebRTC answer
const handleSessionResponse = useCallback((response: SessionResponse) => { const handleSessionResponse = useCallback((response: SessionResponse) => {
if (response.sessionId && response.mode) { if (response.sessionId && response.mode) {
setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending"); setCurrentSession(response.sessionId, response.mode as "primary" | "observer" | "queued" | "pending");
} }
}, [setCurrentSession]); }, [setCurrentSession]);
// Handle approval of primary control request
const handleApprovePrimaryRequest = useCallback(async (requestId: string) => { const handleApprovePrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return; if (!sendFn) return;
@ -63,7 +62,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
}); });
}, [sendFn]); }, [sendFn]);
// Handle denial of primary control request
const handleDenyPrimaryRequest = useCallback(async (requestId: string) => { const handleDenyPrimaryRequest = useCallback(async (requestId: string) => {
if (!sendFn) return; if (!sendFn) return;
@ -80,7 +78,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
}); });
}, [sendFn]); }, [sendFn]);
// Handle approval of new session
const handleApproveNewSession = useCallback(async (sessionId: string) => { const handleApproveNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return; if (!sendFn) return;
@ -97,7 +94,6 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
}); });
}, [sendFn]); }, [sendFn]);
// Handle denial of new session
const handleDenyNewSession = useCallback(async (sessionId: string) => { const handleDenyNewSession = useCallback(async (sessionId: string) => {
if (!sendFn) return; if (!sendFn) return;
@ -114,34 +110,30 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
}); });
}, [sendFn]); }, [sendFn]);
// Handle RPC events
const handleRpcEvent = useCallback((method: string, params: unknown) => { const handleRpcEvent = useCallback((method: string, params: unknown) => {
// Pass session events to the session event handler
if (method === "sessionsUpdated" || if (method === "sessionsUpdated" ||
method === "modeChanged" || method === "modeChanged" ||
method === "connectionModeChanged" ||
method === "otherSessionConnected") { method === "otherSessionConnected") {
handleSessionEvent(method, params); handleSessionEvent(method, params);
} }
// Handle new session approval request (only if approval is required and user has permission) if (method === "newSessionPending" && requireSessionApproval) {
if (method === "newSessionPending" && requireSessionApproval && hasPermission(Permission.SESSION_APPROVE)) { if (isLoadingPermissions || hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(params as NewSessionRequest); setNewSessionRequest(params as NewSessionRequest);
} }
}
// Handle primary control request
if (method === "primaryControlRequested") { if (method === "primaryControlRequested") {
setPrimaryControlRequest(params as PrimaryControlRequest); setPrimaryControlRequest(params as PrimaryControlRequest);
} }
// Handle approval/denial responses
if (method === "primaryControlApproved") { if (method === "primaryControlApproved") {
// Clear requesting state in store
const { setRequestingPrimary } = useSessionStore.getState(); const { setRequestingPrimary } = useSessionStore.getState();
setRequestingPrimary(false); setRequestingPrimary(false);
} }
if (method === "primaryControlDenied") { if (method === "primaryControlDenied") {
// Clear requesting state and show error
const { setRequestingPrimary, setSessionError } = useSessionStore.getState(); const { setRequestingPrimary, setSessionError } = useSessionStore.getState();
setRequestingPrimary(false); setRequestingPrimary(false);
setSessionError("Your primary control request was denied"); setSessionError("Your primary control request was denied");
@ -152,9 +144,14 @@ export function useSessionManagement(sendFn: RpcSendFunction | null) {
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");
} }
}, [handleSessionEvent, hasPermission, requireSessionApproval]); }, [handleSessionEvent, hasPermission, isLoadingPermissions, requireSessionApproval]);
useEffect(() => {
if (!isLoadingPermissions && newSessionRequest && !hasPermission(Permission.SESSION_APPROVE)) {
setNewSessionRequest(null);
}
}, [isLoadingPermissions, hasPermission, newSessionRequest]);
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
clearSession(); clearSession();

View File

@ -0,0 +1,106 @@
import { useState, useEffect, useRef, useCallback, ReactNode } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useSessionStore } from "@/stores/sessionStore";
import { useRTCStore } from "@/hooks/stores";
import { Permission } from "@/types/permissions";
import { PermissionsContextValue } from "@/hooks/usePermissions";
import { PermissionsContext } from "@/contexts/PermissionsContext";
type RpcSendFunction = (method: string, params: Record<string, unknown>, callback: (response: { result?: unknown; error?: { message: string } }) => void) => void;
interface PermissionsResponse {
mode: string;
permissions: Record<string, boolean>;
}
export function PermissionsProvider({ children }: { children: ReactNode }) {
const { currentMode } = useSessionStore();
const { setRpcHidProtocolVersion, rpcHidChannel, rpcDataChannel } = useRTCStore();
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [isLoading, setIsLoading] = useState(true);
const previousCanControl = useRef<boolean>(false);
const pollPermissions = useCallback((send: RpcSendFunction) => {
if (!send) return;
setIsLoading(true);
send("getPermissions", {}, (response: { result?: unknown; error?: { message: string } }) => {
if (!response.error && response.result) {
const result = response.result as PermissionsResponse;
setPermissions(result.permissions);
}
setIsLoading(false);
});
}, []);
const { send } = useJsonRpc();
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
pollPermissions(send);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMode, rpcDataChannel?.readyState]);
const hasPermission = useCallback((permission: Permission): boolean => {
return permissions[permission] === true;
}, [permissions]);
const hasAnyPermission = useCallback((...perms: Permission[]): boolean => {
return perms.some(perm => hasPermission(perm));
}, [hasPermission]);
const hasAllPermissions = useCallback((...perms: Permission[]): boolean => {
return perms.every(perm => hasPermission(perm));
}, [hasPermission]);
useEffect(() => {
const currentCanControl = hasPermission(Permission.KEYBOARD_INPUT) && hasPermission(Permission.MOUSE_INPUT);
const hadControl = previousCanControl.current;
if (currentCanControl && !hadControl && rpcHidChannel?.readyState === "open") {
console.info("Gained control permissions, re-initializing HID");
setRpcHidProtocolVersion(null);
import("@/hooks/hidRpc").then(({ HID_RPC_VERSION, HandshakeMessage }) => {
setTimeout(() => {
if (rpcHidChannel?.readyState === "open") {
const handshakeMessage = new HandshakeMessage(HID_RPC_VERSION);
try {
const data = handshakeMessage.marshal();
rpcHidChannel.send(data as unknown as ArrayBuffer);
console.info("Sent HID handshake after permission change");
} catch (e) {
console.error("Failed to send HID handshake", e);
}
}
}, 100);
});
}
previousCanControl.current = currentCanControl;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions, rpcHidChannel, setRpcHidProtocolVersion]);
const isPrimary = useCallback(() => currentMode === "primary", [currentMode]);
const isObserver = useCallback(() => currentMode === "observer", [currentMode]);
const isPending = useCallback(() => currentMode === "pending", [currentMode]);
const value: PermissionsContextValue = {
permissions,
isLoading,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isPrimary,
isObserver,
isPending,
};
return (
<PermissionsContext.Provider value={value}>
{children}
</PermissionsContext.Provider>
);
}

View File

@ -6,7 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import notifications from "../notifications"; import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbInfoSetting } from "../components/UsbInfoSetting";

View File

@ -4,7 +4,8 @@ import {
} from "@heroicons/react/16/solid"; } from "@heroicons/react/16/solid";
import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { useSettingsStore } from "@/hooks/stores"; import { useSettingsStore } from "@/hooks/stores";
import { notify } from "@/notifications"; import { notify } from "@/notifications";
import Card from "@/components/Card"; import Card from "@/components/Card";

View File

@ -22,7 +22,8 @@ import { LinkButton } from "@components/Button";
import { FeatureFlag } from "@components/FeatureFlag"; import { FeatureFlag } from "@components/FeatureFlag";
import { useUiStore } from "@/hooks/stores"; import { useUiStore } from "@/hooks/stores";
import { useSessionStore } from "@/stores/sessionStore"; import { useSessionStore } from "@/stores/sessionStore";
import { usePermissions, Permission } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() { export default function SettingsRoute() {
@ -34,7 +35,7 @@ export default function SettingsRoute() {
useEffect(() => { useEffect(() => {
if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) { if (!isLoading && !permissions[Permission.SETTINGS_ACCESS] && currentMode !== null) {
navigate("/devices/local", { replace: true }); navigate("/", { replace: true });
} }
}, [permissions, isLoading, currentMode, navigate]); }, [permissions, isLoading, currentMode, navigate]);

View File

@ -18,7 +18,6 @@ import useWebSocket from "react-use-websocket";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "@/api"; import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import { usePermissions, Permission } from "@/hooks/usePermissions";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
KeyboardLedState, KeyboardLedState,
@ -54,6 +53,7 @@ import {
} from "@/components/VideoOverlay"; } from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
import { PermissionsProvider } from "@/providers/PermissionsProvider";
import { DeviceStatus } from "@routes/welcome-local"; import { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion"; import { useVersion } from "@/hooks/useVersion";
import { useSessionManagement } from "@/hooks/useSessionManagement"; import { useSessionManagement } from "@/hooks/useSessionManagement";
@ -159,7 +159,6 @@ export default function KvmIdRoute() {
const { nickname, setNickname } = useSharedSessionStore(); const { nickname, setNickname } = useSharedSessionStore();
const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore(); const { setRequireSessionApproval, setRequireSessionNickname } = useSettingsStore();
const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null); const [globalSessionSettings, setGlobalSessionSettings] = useState<{requireApproval: boolean, requireNickname: boolean} | null>(null);
const { hasPermission } = usePermissions();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback( const cleanupAndStopReconnecting = useCallback(
@ -549,44 +548,6 @@ export default function KvmIdRoute() {
const rpcDataChannel = pc.createDataChannel("rpc"); const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => { rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel); setRpcDataChannel(rpcDataChannel);
// Fetch global session settings
const fetchSettings = () => {
// Only fetch settings if user has permission to read settings
if (!hasPermission(Permission.SETTINGS_READ)) {
return;
}
const id = Math.random().toString(36).substring(2);
const message = JSON.stringify({ jsonrpc: "2.0", method: "getSessionSettings", params: {}, id });
const handler = (event: MessageEvent) => {
try {
const response = JSON.parse(event.data);
if (response.id === id) {
rpcDataChannel.removeEventListener("message", handler);
if (response.result) {
setGlobalSessionSettings(response.result);
// Also update the settings store for approval handling
setRequireSessionApproval(response.result.requireApproval);
setRequireSessionNickname(response.result.requireNickname);
}
}
} catch {
// Ignore parse errors
}
};
rpcDataChannel.addEventListener("message", handler);
rpcDataChannel.send(message);
// Clean up after timeout
setTimeout(() => {
rpcDataChannel.removeEventListener("message", handler);
}, 5000);
};
fetchSettings();
}; };
const rpcHidChannel = pc.createDataChannel("hidrpc"); const rpcHidChannel = pc.createDataChannel("hidrpc");
@ -627,9 +588,6 @@ export default function KvmIdRoute() {
setRpcHidUnreliableNonOrderedChannel, setRpcHidUnreliableNonOrderedChannel,
setRpcHidUnreliableChannel, setRpcHidUnreliableChannel,
setTransceiver, setTransceiver,
hasPermission,
setRequireSessionApproval,
setRequireSessionNickname,
]); ]);
useEffect(() => { useEffect(() => {
@ -722,6 +680,7 @@ export default function KvmIdRoute() {
// Handle session-related events // Handle session-related events
if (resp.method === "sessionsUpdated" || if (resp.method === "sessionsUpdated" ||
resp.method === "modeChanged" || resp.method === "modeChanged" ||
resp.method === "connectionModeChanged" ||
resp.method === "otherSessionConnected" || resp.method === "otherSessionConnected" ||
resp.method === "primaryControlRequested" || resp.method === "primaryControlRequested" ||
resp.method === "primaryControlApproved" || resp.method === "primaryControlApproved" ||
@ -735,7 +694,6 @@ export default function KvmIdRoute() {
setAccessDenied(true); setAccessDenied(true);
} }
// Keep legacy behavior for otherSessionConnected
if (resp.method === "otherSessionConnected") { if (resp.method === "otherSessionConnected") {
navigateTo("/other-session"); navigateTo("/other-session");
} }
@ -807,13 +765,11 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
send("getVideoState", {}, (resp: JsonRpcResponse) => { send("getVideoState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return; if ("error" in resp) return;
const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0]; const hdmiState = resp.result as Parameters<VideoState["setHdmiState"]>[0];
setHdmiState(hdmiState); setHdmiState(hdmiState);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rpcDataChannel?.readyState, send, setHdmiState]); }, [rpcDataChannel?.readyState, send, setHdmiState]);
const [needLedState, setNeedLedState] = useState(true); const [needLedState, setNeedLedState] = useState(true);
@ -822,7 +778,6 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needLedState) return; if (!needLedState) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
@ -834,7 +789,6 @@ export default function KvmIdRoute() {
} }
setNeedLedState(false); setNeedLedState(false);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]);
const [needKeyDownState, setNeedKeyDownState] = useState(true); const [needKeyDownState, setNeedKeyDownState] = useState(true);
@ -843,7 +797,6 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return; if (rpcDataChannel?.readyState !== "open") return;
if (!needKeyDownState) return; if (!needKeyDownState) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
send("getKeyDownState", {}, (resp: JsonRpcResponse) => { send("getKeyDownState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) { if ("error" in resp) {
@ -861,7 +814,6 @@ export default function KvmIdRoute() {
} }
setNeedKeyDownState(false); setNeedKeyDownState(false);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]); }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeysDownState, setHidRpcDisabled]);
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
@ -895,7 +847,6 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (appVersion) return; if (appVersion) return;
if (!hasPermission(Permission.VIDEO_VIEW)) return;
getLocalVersion(); getLocalVersion();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -939,6 +890,7 @@ export default function KvmIdRoute() {
]); ]);
return ( return (
<PermissionsProvider>
<FeatureFlagProvider appVersion={appVersion}> <FeatureFlagProvider appVersion={appVersion}>
{!outlet && otaState.updating && ( {!outlet && otaState.updating && (
<AnimatePresence> <AnimatePresence>
@ -1114,6 +1066,7 @@ export default function KvmIdRoute() {
show={currentMode === "pending"} show={currentMode === "pending"}
/> />
</FeatureFlagProvider> </FeatureFlagProvider>
</PermissionsProvider>
); );
} }

View File

@ -0,0 +1,30 @@
export enum Permission {
VIDEO_VIEW = "video.view",
KEYBOARD_INPUT = "keyboard.input",
MOUSE_INPUT = "mouse.input",
PASTE = "clipboard.paste",
SESSION_TRANSFER = "session.transfer",
SESSION_APPROVE = "session.approve",
SESSION_KICK = "session.kick",
SESSION_REQUEST_PRIMARY = "session.request_primary",
SESSION_RELEASE_PRIMARY = "session.release_primary",
SESSION_MANAGE = "session.manage",
MOUNT_MEDIA = "mount.media",
UNMOUNT_MEDIA = "mount.unmedia",
MOUNT_LIST = "mount.list",
EXTENSION_MANAGE = "extension.manage",
EXTENSION_ATX = "extension.atx",
EXTENSION_DC = "extension.dc",
EXTENSION_SERIAL = "extension.serial",
EXTENSION_WOL = "extension.wol",
SETTINGS_READ = "settings.read",
SETTINGS_WRITE = "settings.write",
SETTINGS_ACCESS = "settings.access",
SYSTEM_REBOOT = "system.reboot",
SYSTEM_UPDATE = "system.update",
SYSTEM_NETWORK = "system.network",
POWER_CONTROL = "power.control",
USB_CONTROL = "usb.control",
TERMINAL_ACCESS = "terminal.access",
SERIAL_ACCESS = "serial.access",
}