diff --git a/package.json b/package.json index 96b48e7..0ab00b6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@blueprintjs/core": "5.7.1", "@blueprintjs/select": "5.0.19", + "@braintree/sanitize-url": "^7.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "^4.6.1", diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index ff83560..17ab388 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -5,12 +5,13 @@ import { useAuth } from "../../contexts/AuthContext"; import { useStorage } from "../../contexts/StorageContext/StorageContext"; const Footer: React.FC = () => { - const { authState } = useAuth(); + const { authState, resetAuth } = useAuth(); const { settings } = useStorage(); const handleLogout = () => { - const signoutUrl = `${settings.signadotUrls.dashboardUrl}/signout`; + const signoutUrl = new URL(`/signout`, settings.signadotUrls.dashboardUrl).toString(); window.open(signoutUrl, '_blank'); + resetAuth(); }; return ( diff --git a/src/components/Frame/Frame.tsx b/src/components/Frame/Frame.tsx index d7434e6..393765d 100644 --- a/src/components/Frame/Frame.tsx +++ b/src/components/Frame/Frame.tsx @@ -46,7 +46,7 @@ const Home = () => { setCurrentRoutingKey(e.routingKey)} - orgName={authState?.org.name} + orgName={authState.org?.name} />
{pinnedRoutingEntityData ? ( @@ -70,18 +70,18 @@ const Home = () => { }; const Frame = () => { - const { authState, isLoading } = useAuth(); + const { authState } = useAuth(); const { goToView } = useRouteView(); useEffect(() => { - if (isLoading) { + if (authState.status === "loading") { goToView("loading"); - } else if (authState) { + } else if (authState.status === "authenticated") { goToView("home"); } else { goToView("login"); } - }, [authState, isLoading]); + }, [authState]); return (
diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index b9fea73..cdf98dc 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -14,7 +14,7 @@ interface Props { const Layout: React.FC = ({ children }) => { const { currentView, goToView } = useRouteView(); - const { init, settings, setSettings, isAuthenticated } = useStorage(); + const { isStoreLoaded, settings, setSettings, isAuthenticated } = useStorage(); const { authState } = useAuth(); const handleHomeChange = () => { @@ -22,7 +22,7 @@ const Layout: React.FC = ({ children }) => { }; return ( - init && ( + isStoreLoaded && (
diff --git a/src/components/ListRouteEntries/hooks.ts b/src/components/ListRouteEntries/hooks.ts index a46103a..fe5d845 100644 --- a/src/components/ListRouteEntries/hooks.ts +++ b/src/components/ListRouteEntries/hooks.ts @@ -13,15 +13,39 @@ export const useFetchRoutingEntries = () => { data: sandboxes, error: sandboxesError, isLoading: sandboxesLoading, - } = useQuery("sandboxes", () => - fetchSandboxes(settings.signadotUrls.apiUrl || "", authState?.org.name || ""), + } = useQuery( + "sandboxes", + () => { + if (authState.status !== "authenticated") { + throw new Error("Not authenticated"); + } + if (!settings.signadotUrls.apiUrl) { + throw new Error("API URL not configured"); + } + return fetchSandboxes(settings.signadotUrls.apiUrl, authState.org.name); + }, + { + enabled: authState.status === "authenticated" && !!settings.signadotUrls.apiUrl + } ); const { data: routegroups, error: routegroupsError, isLoading: routegroupsLoading, - } = useQuery("routegroups", () => - fetchRouteGroups(settings.signadotUrls.apiUrl || "", authState?.org.name || ""), + } = useQuery( + "routegroups", + () => { + if (authState.status !== "authenticated") { + throw new Error("Not authenticated"); + } + if (!settings.signadotUrls.apiUrl) { + throw new Error("API URL not configured"); + } + return fetchRouteGroups(settings.signadotUrls.apiUrl, authState.org.name); + }, + { + enabled: authState.status === "authenticated" && !!settings.signadotUrls.apiUrl + } ); // TODO: Handle error and loading too. diff --git a/src/components/ListRouteEntries/queries.ts b/src/components/ListRouteEntries/queries.ts index db68c76..9905605 100644 --- a/src/components/ListRouteEntries/queries.ts +++ b/src/components/ListRouteEntries/queries.ts @@ -6,7 +6,7 @@ import { getApiUrl } from "../Settings/api"; export const fetchSandboxes = async (apiUrl: string, orgName?: string): Promise => { return new Promise(async (resolve, reject) => { - fetch(`${apiUrl}/api/v2/orgs/${orgName}/sandboxes`) + fetch(new URL(`/api/v2/orgs/${orgName}/sandboxes`, apiUrl).toString()) .then((response) => { if (!response.ok) { throw new Error("Failed to fetch sandboxes"); @@ -20,7 +20,7 @@ export const fetchSandboxes = async (apiUrl: string, orgName?: string): Promise< export const fetchRouteGroups = async (apiUrl: string, orgName?: string): Promise => { return new Promise(async (resolve, reject) => { - fetch(`${apiUrl}/api/v2/orgs/${orgName}/routegroups`) + fetch(new URL(`/api/v2/orgs/${orgName}/routegroups`, apiUrl).toString()) .then((response) => { if (!response.ok) { throw new Error("Failed to fetch route groups"); @@ -34,7 +34,7 @@ export const fetchRouteGroups = async (apiUrl: string, orgName?: string): Promis export const fetchClusters = async (apiUrl: string, orgName: string): Promise => { return new Promise(async (resolve, reject) => { - fetch(`${apiUrl}/api/v2/orgs/${orgName}/clusters`) + fetch(new URL(`/api/v2/orgs/${orgName}/clusters`, apiUrl).toString()) .then((response) => { if (!response.ok) { throw new Error("Failed to fetch clusters"); diff --git a/src/components/PinnedRouteGroup/PinnedRouteGroup.tsx b/src/components/PinnedRouteGroup/PinnedRouteGroup.tsx index e81198d..2d6be8d 100644 --- a/src/components/PinnedRouteGroup/PinnedRouteGroup.tsx +++ b/src/components/PinnedRouteGroup/PinnedRouteGroup.tsx @@ -12,9 +12,9 @@ interface Props { const getEntityDashboardURL = (dashboardUrl: string, routingEntity: RoutingEntity): string | undefined => { switch (routingEntity.type) { case RoutingEntityType.Sandbox: - return dashboardUrl + `/sandbox/name/${routingEntity.name}/overview`; + return new URL(`/sandbox/name/${routingEntity.name}/overview`, dashboardUrl).toString(); case RoutingEntityType.RouteGroup: - return dashboardUrl + `/routegroups/${routingEntity.name}`; + return new URL(`/routegroups/${routingEntity.name}`, dashboardUrl).toString(); } return undefined; }; diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index 08b1033..06cb891 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -15,7 +15,8 @@ import { STAGING_SIGNADOT_PREVIEW_URL, STAGING_SIGNADOT_DASHBOARD_URL, } from "../../contexts/StorageContext/defaults"; - +import { useAuth } from "../../contexts/AuthContext"; +import { sanitizeUrl } from "@braintree/sanitize-url"; const AUTH_SESSION_COOKIE_NAME = "signadot-auth"; type Environment = "production" | "staging" | "custom"; @@ -44,6 +45,7 @@ const Settings: React.FC = ({ onClose }) => { const { settings, traceparent, setSettings, setTraceparent } = useStorage(); const [isExtraSettingsOpen, setIsExtraSettingsOpen] = React.useState(false); + const { resetAuth } = useAuth(); useHotkeys("ctrl+shift+u", () => setIsExtraSettingsOpen(!isExtraSettingsOpen), { enableOnFormTags: true, @@ -105,9 +107,14 @@ const Settings: React.FC = ({ onClose }) => { const isReadOnly = selectedEnv === "production" || selectedEnv === "staging"; const handleSave = () => { - const cleanApiUrl = unsavedValues.apiUrl.replace(/\/+$/, ""); - const cleanPreviewUrl = unsavedValues.previewUrl.replace(/\/+$/, ""); - const cleanDashboardUrl = unsavedValues.dashboardUrl.replace(/\/+$/, ""); + const cleanApiUrl = sanitizeUrl(unsavedValues.apiUrl); + const cleanPreviewUrl = sanitizeUrl(unsavedValues.previewUrl); + const cleanDashboardUrl = sanitizeUrl(unsavedValues.dashboardUrl); + + // If there is a new apiUrl, we need to reset the auth state + if (cleanApiUrl !== settings.signadotUrls.apiUrl || cleanPreviewUrl !== settings.signadotUrls.previewUrl || cleanDashboardUrl !== settings.signadotUrls.dashboardUrl) { + resetAuth(); + } setSettings({ enabled: settings.enabled, diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index c507821..f9c9d62 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -2,30 +2,31 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { auth } from "./auth"; import Layout from "../components/Layout/Layout"; import { useStorage } from "./StorageContext/StorageContext"; -import { Intent, Spinner, SpinnerSize } from "@blueprintjs/core"; - -const loadingIconPath = chrome.runtime.getURL("images/loading.gif"); interface Props { children: React.ReactNode; } -interface AuthState { - org: { - name: string; - displayName?: string; - }; - user: { - firstName?: string; - lastName?: string; - email?: string; - }; +interface User { + firstName?: string; + lastName?: string; + email?: string; +} + +interface Org { + name: string; + displayName?: string; } +type AuthState = + | { status: "loading"; user: undefined; org: undefined } + | { status: "unauthenticated"; user: undefined; org: undefined } + | { status: "authenticated"; user: User; org: Org }; + // Define the shape of the context interface AuthContextType { - authState?: AuthState; - isLoading: boolean; + authState: AuthState; + resetAuth: () => void; } interface GetOrgsResponse { @@ -51,31 +52,54 @@ const AuthContext = createContext(undefined); // AuthProvider component export const AuthProvider: React.FC = ({ children }) => { - const [authState, setAuthState] = useState(undefined); - const [authenticated, setAuthenticated] = useState(undefined); - const [isLoading, setIsLoading] = useState(true); - const { settings, setIsAuthenticated } = useStorage(); + const [authState, setAuthState] = useState({ + status: "loading", + user: undefined, + org: undefined + }); + const { settings, isStoreLoaded, setIsAuthenticated } = useStorage(); const { apiUrl, previewUrl } = settings.signadotUrls; + const resetAuth = () => { + setAuthState({ + status: "unauthenticated", + user: undefined, + org: undefined + }); + setIsAuthenticated(false); + }; + useEffect(() => { - if (!apiUrl || !previewUrl) return; + if (!apiUrl || !previewUrl || !isStoreLoaded) return; + + setAuthState({ + status: "loading", + user: undefined, + org: undefined + }); auth( async (authenticated) => { if (!authenticated) { console.log("Not authenticated!"); - setAuthenticated(false); - setIsLoading(false); + setAuthState({ + status: "unauthenticated", + user: undefined, + org: undefined + }); return; } try { - const response = await fetch(`${apiUrl}/api/v1/orgs`); + const response = await fetch(new URL("/api/v1/orgs", apiUrl).toString()); if (response.status === 401 || !response.ok) { - setAuthenticated(false); setIsAuthenticated(false); - setIsLoading(false); + setAuthState({ + status: "unauthenticated", + user: undefined, + org: undefined + }); return; } @@ -87,40 +111,42 @@ export const AuthProvider: React.FC = ({ children }) => { } setAuthState({ + status: "authenticated", org: data.orgs[0], user: { firstName: data.user.firstName?.String, lastName: data.user.lastName?.String, email: data.user.email - }, + } }); - setAuthenticated(true); setIsAuthenticated(true); - setIsLoading(false); } catch (error) { console.error("Error fetching org:", error); - setAuthenticated(false); setIsAuthenticated(false); - setIsLoading(false); + setAuthState({ + status: "unauthenticated", + user: undefined, + org: undefined + }); } }, { apiUrl, previewUrl }, ); - }, [apiUrl, previewUrl]); + }, [apiUrl, previewUrl, isStoreLoaded]); useEffect(() => { - if (authState === undefined) { + if (authState.status === "unauthenticated") { setIsAuthenticated(false); } - if (authState) { + if (authState.status === "authenticated") { setIsAuthenticated(true); } }, [authState]); return ( - + {children} ); diff --git a/src/contexts/StorageContext/StorageContext.tsx b/src/contexts/StorageContext/StorageContext.tsx index a0efbe7..2a2cd3b 100644 --- a/src/contexts/StorageContext/StorageContext.tsx +++ b/src/contexts/StorageContext/StorageContext.tsx @@ -46,10 +46,6 @@ export const StorageProvider: React.FC = ({ children }) => const [isStorageLoaded, setIsStorageLoaded] = useState(false); - useEffect(() => { - setIsStorageLoaded(true); - }, []); - // Load initial values from browser storage useEffect(() => { const loadInitialValues = async () => { @@ -128,6 +124,8 @@ export const StorageProvider: React.FC = ({ children }) => return newState; }); + + setIsStorageLoaded(true); }; loadInitialValues(); @@ -175,7 +173,7 @@ export const StorageProvider: React.FC = ({ children }) => }; const value = { - init: isStorageLoaded, + isStoreLoaded: isStorageLoaded, isAuthenticated: state.isAuthenticated, settings: state.settings, traceparent: state.traceparent, diff --git a/src/contexts/StorageContext/defaults.ts b/src/contexts/StorageContext/defaults.ts index 76537f2..a3a1f3e 100644 --- a/src/contexts/StorageContext/defaults.ts +++ b/src/contexts/StorageContext/defaults.ts @@ -1,14 +1,14 @@ import { Settings, TraceparentConfig } from "./types"; // Production environment URLs -export const PROD_SIGNADOT_PREVIEW_URL = "https://browser-extension-auth-redirect.preview.signadot.com"; -export const PROD_SIGNADOT_DASHBOARD_URL = "https://app.signadot.com"; -export const PROD_SIGNADOT_API_URL = "https://api.signadot.com"; +export const PROD_SIGNADOT_PREVIEW_URL = "https://browser-extension-auth-redirect.preview.signadot.com/"; +export const PROD_SIGNADOT_DASHBOARD_URL = "https://app.signadot.com/"; +export const PROD_SIGNADOT_API_URL = "https://api.signadot.com/"; // Staging environment URLs -export const STAGING_SIGNADOT_PREVIEW_URL = "https://browser-extension-auth-redirect.preview.staging.signadot.com"; -export const STAGING_SIGNADOT_DASHBOARD_URL = "https://app.staging.signadot.com"; -export const STAGING_SIGNADOT_API_URL = "https://api.staging.signadot.com"; +export const STAGING_SIGNADOT_PREVIEW_URL = "https://browser-extension-auth-redirect.preview.staging.signadot.com/"; +export const STAGING_SIGNADOT_DASHBOARD_URL = "https://app.staging.signadot.com/"; +export const STAGING_SIGNADOT_API_URL = "https://api.staging.signadot.com/"; // Default URLs (using production) export const DEFAULT_SIGNADOT_PREVIEW_URL = PROD_SIGNADOT_PREVIEW_URL; diff --git a/src/contexts/StorageContext/types.ts b/src/contexts/StorageContext/types.ts index 8ad193f..2c7e9b8 100644 --- a/src/contexts/StorageContext/types.ts +++ b/src/contexts/StorageContext/types.ts @@ -30,7 +30,7 @@ export type Header = { }; export type StorageContextType = { - init: boolean; + isStoreLoaded: boolean; isAuthenticated: boolean; settings: Settings; traceparent: TraceparentConfig; diff --git a/yarn.lock b/yarn.lock index c882884..84260ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -405,6 +405,11 @@ classnames "^2.3.1" tslib "~2.6.2" +"@braintree/sanitize-url@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz#15e19737d946559289b915e5dad3b4c28407735e" + integrity sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw== + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz"