diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts index 9f048393034e..a07527992fa8 100644 --- a/frontend/src/api/github.ts +++ b/frontend/src/api/github.ts @@ -52,9 +52,7 @@ export const retrieveGitHubUserRepositories = async ( export const retrieveGitHubUser = async () => { const response = await github.get("/user", { transformResponse: (data) => { - const parsedData: GitHubUser | GitHubErrorReponse = JSON.parse( - data as string, - ); + const parsedData: GitHubUser | GitHubErrorReponse = JSON.parse(data); if (isGitHubErrorReponse(parsedData)) { throw new Error(parsedData.message); diff --git a/frontend/src/api/invariant-service.ts b/frontend/src/api/invariant-service.ts new file mode 100644 index 000000000000..324aa4ea86d1 --- /dev/null +++ b/frontend/src/api/invariant-service.ts @@ -0,0 +1,30 @@ +import { openHands } from "./open-hands-axios"; + +class InvariantService { + static async getPolicy() { + const { data } = await openHands.get("/api/security/policy"); + return data.policy; + } + + static async getRiskSeverity() { + const { data } = await openHands.get("/api/security/settings"); + return data.RISK_SEVERITY; + } + + static async getTraces() { + const { data } = await openHands.get("/api/security/export-trace"); + return data; + } + + static async updatePolicy(policy: string) { + await openHands.post("/api/security/policy", { policy }); + } + + static async updateRiskSeverity(riskSeverity: number) { + await openHands.post("/api/security/settings", { + RISK_SEVERITY: riskSeverity, + }); + } +} + +export default InvariantService; diff --git a/frontend/src/components/modals/security/invariant/invariant.tsx b/frontend/src/components/modals/security/invariant/invariant.tsx index f6ec677c6d44..5813a7e3ff9d 100644 --- a/frontend/src/components/modals/security/invariant/invariant.tsx +++ b/frontend/src/components/modals/security/invariant/invariant.tsx @@ -1,10 +1,11 @@ -import React, { useState, useRef, useCallback, useEffect } from "react"; +import React from "react"; import { useSelector } from "react-redux"; import { IoAlertCircle } from "react-icons/io5"; import { useTranslation } from "react-i18next"; import { Editor, Monaco } from "@monaco-editor/react"; import { editor } from "monaco-editor"; import { Button, Select, SelectItem } from "@nextui-org/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { RootState } from "#/store"; import { ActionSecurityRisk, @@ -12,42 +13,86 @@ import { } from "#/state/security-analyzer-slice"; import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; import { I18nKey } from "#/i18n/declaration"; -import { request } from "#/services/api"; import toast from "#/utils/toast"; import InvariantLogoIcon from "./assets/logo"; +import { getFormattedDateTime } from "#/utils/gget-formatted-datetime"; +import { downloadJSON } from "#/utils/download-json"; +import InvariantService from "#/api/invariant-service"; type SectionType = "logs" | "policy" | "settings"; function SecurityInvariant(): JSX.Element { const { t } = useTranslation(); const { logs } = useSelector((state: RootState) => state.securityAnalyzer); - const [activeSection, setActiveSection] = useState("logs"); - const logsRef = useRef(null); - const [policy, setPolicy] = useState(""); - const [selectedRisk, setSelectedRisk] = useState(ActionSecurityRisk.MEDIUM); + const [activeSection, setActiveSection] = React.useState("logs"); + const [policy, setPolicy] = React.useState(""); + const [selectedRisk, setSelectedRisk] = React.useState( + ActionSecurityRisk.MEDIUM, + ); + + const logsRef = React.useRef(null); + + const { data: fetchedPolicy } = useQuery({ + queryKey: ["policy"], + queryFn: InvariantService.getPolicy, + }); + + const { data: riskSeverity } = useQuery({ + queryKey: ["risk_severity"], + queryFn: InvariantService.getRiskSeverity, + }); + + const { data: traces, refetch: exportTraces } = useQuery({ + queryKey: ["traces"], + queryFn: InvariantService.getTraces, + enabled: false, + }); + + React.useEffect(() => { + if (fetchedPolicy) { + setPolicy(fetchedPolicy); + } + }, [fetchedPolicy]); + + React.useEffect(() => { + if (traces) { + toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE)); - useEffect(() => { - const fetchPolicy = async () => { - const data = await request(`/api/security/policy`); - setPolicy(data.policy); - }; - const fetchRiskSeverity = async () => { - const data = await request(`/api/security/settings`); + const filename = `openhands-trace-${getFormattedDateTime()}.json`; + downloadJSON(traces, filename); + } + }, [traces]); + + React.useEffect(() => { + if (riskSeverity) { setSelectedRisk( - data.RISK_SEVERITY === 0 + riskSeverity === 0 ? ActionSecurityRisk.LOW - : data.RISK_SEVERITY || ActionSecurityRisk.MEDIUM, + : riskSeverity || ActionSecurityRisk.MEDIUM, ); - }; + } + }, [riskSeverity]); - fetchPolicy(); - fetchRiskSeverity(); - }, []); + const { mutate: updatePolicy } = useMutation({ + mutationFn: (variables: { policy: string }) => + InvariantService.updatePolicy(variables.policy), + onSuccess: () => { + toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE)); + }, + }); + + const { mutate: updateRiskSeverity } = useMutation({ + mutationFn: (variables: { riskSeverity: number }) => + InvariantService.updateRiskSeverity(variables.riskSeverity), + onSuccess: () => { + toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE)); + }, + }); useScrollToBottom(logsRef); - const getRiskColor = useCallback((risk: ActionSecurityRisk) => { + const getRiskColor = React.useCallback((risk: ActionSecurityRisk) => { switch (risk) { case ActionSecurityRisk.LOW: return "text-green-500"; @@ -61,7 +106,7 @@ function SecurityInvariant(): JSX.Element { } }, []); - const getRiskText = useCallback( + const getRiskText = React.useCallback( (risk: ActionSecurityRisk) => { switch (risk) { case ActionSecurityRisk.LOW: @@ -78,7 +123,7 @@ function SecurityInvariant(): JSX.Element { [t], ); - const handleEditorDidMount = useCallback( + const handleEditorDidMount = React.useCallback( (_: editor.IStandaloneCodeEditor, monaco: Monaco): void => { monaco.editor.defineTheme("my-theme", { base: "vs-dark", @@ -94,76 +139,12 @@ function SecurityInvariant(): JSX.Element { [], ); - const getFormattedDateTime = () => { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hour = String(now.getHours()).padStart(2, "0"); - const minute = String(now.getMinutes()).padStart(2, "0"); - const second = String(now.getSeconds()).padStart(2, "0"); - - return `${year}-${month}-${day}-${hour}-${minute}-${second}`; - }; - - // Function to download JSON data as a file - const downloadJSON = (data: object, filename: string) => { - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - - async function exportTraces(): Promise { - const data = await request(`/api/security/export-trace`); - toast.info(t(I18nKey.INVARIANT$TRACE_EXPORTED_MESSAGE)); - - const filename = `openhands-trace-${getFormattedDateTime()}.json`; - downloadJSON(data, filename); - } - - async function updatePolicy(): Promise { - await request(`/api/security/policy`, { - method: "POST", - body: JSON.stringify({ policy }), - }); - toast.info(t(I18nKey.INVARIANT$POLICY_UPDATED_MESSAGE)); - } - - async function updateSettings(): Promise { - const payload = { RISK_SEVERITY: selectedRisk }; - await request(`/api/security/settings`, { - method: "POST", - body: JSON.stringify(payload), - }); - toast.info(t(I18nKey.INVARIANT$SETTINGS_UPDATED_MESSAGE)); - } - - const handleExportTraces = useCallback(() => { - exportTraces(); - }, [exportTraces]); - - const handleUpdatePolicy = useCallback(() => { - updatePolicy(); - }, [updatePolicy]); - - const handleUpdateSettings = useCallback(() => { - updateSettings(); - }, [updateSettings]); - const sections: { [key in SectionType]: JSX.Element } = { logs: ( <>

{t(I18nKey.INVARIANT$LOG_LABEL)}

-
@@ -196,7 +177,10 @@ function SecurityInvariant(): JSX.Element { <>

{t(I18nKey.INVARIANT$POLICY_LABEL)}

-
@@ -206,7 +190,7 @@ function SecurityInvariant(): JSX.Element { height="100%" onMount={handleEditorDidMount} value={policy} - onChange={(value) => setPolicy(`${value}`)} + onChange={(value) => setPolicy(value || "")} /> @@ -215,7 +199,10 @@ function SecurityInvariant(): JSX.Element { <>

{t(I18nKey.INVARIANT$SETTINGS_LABEL)}

-
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts deleted file mode 100644 index 82f4468c1b08..000000000000 --- a/frontend/src/services/api.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { getToken, getGitHubToken } from "./auth"; -import toast from "#/utils/toast"; - -const WAIT_FOR_AUTH_DELAY_MS = 500; - -const UNAUTHED_ROUTE_PREFIXES = [ - "/api/authenticate", - "/api/options/", - "/config.json", - "/api/github/callback", -]; - -export async function request( - url: string, - options: RequestInit = {}, - disableToast: boolean = false, - returnResponse: boolean = false, - maxRetries: number = 3, - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -): Promise { - if (maxRetries < 0) { - throw new Error("Max retries exceeded"); - } - const onFail = (msg: string) => { - if (!disableToast) { - toast.error("api", msg); - } - throw new Error(msg); - }; - - const needsAuth = !UNAUTHED_ROUTE_PREFIXES.some((prefix) => - url.startsWith(prefix), - ); - const token = getToken(); - const githubToken = getGitHubToken(); - if (!token && needsAuth) { - return new Promise((resolve) => { - setTimeout(() => { - resolve( - request(url, options, disableToast, returnResponse, maxRetries - 1), - ); - }, WAIT_FOR_AUTH_DELAY_MS); - }); - } - if (token) { - // eslint-disable-next-line no-param-reassign - options.headers = { - ...(options.headers || {}), - Authorization: `Bearer ${token}`, - }; - } - if (githubToken) { - // eslint-disable-next-line no-param-reassign - options.headers = { - ...(options.headers || {}), - "X-GitHub-Token": githubToken, - }; - } - - let response = null; - try { - response = await fetch(url, options); - } catch (e) { - onFail(`Error fetching ${url}`); - } - if (response?.status === 401 && !url.startsWith("/api/authenticate")) { - await request( - "/api/authenticate", - { - method: "POST", - }, - true, - ); - return request(url, options, disableToast, returnResponse, maxRetries - 1); - } - if (response?.status && response?.status >= 400) { - onFail( - `${response.status} error while fetching ${url}: ${response?.statusText}`, - ); - } - if (!response?.ok) { - onFail(`Error fetching ${url}: ${response?.statusText}`); - } - - if (returnResponse) { - return response; - } - - try { - return await (response && response.json()); - } catch (e) { - onFail(`Error parsing JSON from ${url}`); - } - return null; -} diff --git a/frontend/src/utils/download-json.ts b/frontend/src/utils/download-json.ts new file mode 100644 index 000000000000..700109be109e --- /dev/null +++ b/frontend/src/utils/download-json.ts @@ -0,0 +1,13 @@ +export const downloadJSON = (data: object, filename: string) => { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; diff --git a/frontend/src/utils/gget-formatted-datetime.ts b/frontend/src/utils/gget-formatted-datetime.ts new file mode 100644 index 000000000000..24dafa3257b6 --- /dev/null +++ b/frontend/src/utils/gget-formatted-datetime.ts @@ -0,0 +1,11 @@ +export const getFormattedDateTime = () => { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hour = String(now.getHours()).padStart(2, "0"); + const minute = String(now.getMinutes()).padStart(2, "0"); + const second = String(now.getSeconds()).padStart(2, "0"); + + return `${year}-${month}-${day}-${hour}-${minute}-${second}`; +};