diff --git a/package.json b/package.json index 9293f3f..898322c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@saleor/app-sdk", - "version": "0.11.0", + "version": "0.12.0-1", "description": "SDK for building great Saleor Apps", "types": "index.d.ts", "main": "index.js", @@ -67,8 +67,8 @@ "watchlist": "^0.3.1", "react": "^18.2.0", "react-dom": "18.2.0", - "@types/react": "17.0.49", - "@types/react-dom": "^17.0.11" + "@types/react": "18.0.21", + "@types/react-dom": "^18.0.5" }, "lint-staged": { "*.{js,ts,tsx}": "eslint --cache --fix", @@ -101,6 +101,11 @@ "import": "./app-bridge/index.mjs", "require": "./app-bridge/index.js" }, + "./util": { + "types": "./util/index.d.ts", + "import": "./util/index.mjs", + "require": "./util/index.js" + }, "./handlers/next": { "types": "./handlers/next/index.d.ts", "import": "./handlers/next/index.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a7987..dcdf147 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ specifiers: '@types/debug': ^4.1.7 '@types/node': ^18.7.15 '@types/node-fetch': ^2.6.2 - '@types/react': 17.0.49 - '@types/react-dom': ^17.0.11 + '@types/react': 18.0.21 + '@types/react-dom': ^18.0.5 '@types/uuid': ^8.3.4 '@typescript-eslint/eslint-plugin': ^5.36.1 '@typescript-eslint/parser': ^5.36.1 @@ -60,8 +60,8 @@ devDependencies: '@types/debug': 4.1.7 '@types/node': 18.7.15 '@types/node-fetch': 2.6.2 - '@types/react': 17.0.49 - '@types/react-dom': 17.0.17 + '@types/react': 18.0.21 + '@types/react-dom': 18.0.6 '@types/uuid': 8.3.4 '@typescript-eslint/eslint-plugin': 5.36.1_lbwfnm54o3pmr3ypeqp3btnera '@typescript-eslint/parser': 5.36.1_yqf6kl63nyoq5megxukfnom5rm @@ -898,28 +898,14 @@ packages: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} dev: true - /@types/react-dom/17.0.17: - resolution: {integrity: sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==} - dependencies: - '@types/react': 17.0.49 - dev: true - /@types/react-dom/18.0.6: resolution: {integrity: sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==} dependencies: - '@types/react': 18.0.18 + '@types/react': 18.0.21 dev: true - /@types/react/17.0.49: - resolution: {integrity: sha512-CCBPMZaPhcKkYUTqFs/hOWqKjPxhTEmnZWjlHHgIMop67DsXywf9B5Os9Hz8KSacjNOgIdnZVJamwl232uxoPg==} - dependencies: - '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.2 - csstype: 3.1.0 - dev: true - - /@types/react/18.0.18: - resolution: {integrity: sha512-6hI08umYs6NaiHFEEGioXnxJ+oEhY3eRz8VCUaudZmGdtvPviCJB8mgaMxaDWAdPSYd4eFavrPk2QIolwbLYrg==} + /@types/react/18.0.21: + resolution: {integrity: sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA==} dependencies: '@types/prop-types': 15.7.5 '@types/scheduler': 0.16.2 diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index 55a9861..3b15aec 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -6,6 +6,8 @@ export * from "./actions"; export * from "./app-bridge-provider"; export * from "./events"; export * from "./types"; +export * from "./use-dashboard-token"; +export * from "./with-authorization"; /** * @deprecated use new AppBridge(), createApp will be removed diff --git a/src/app-bridge/use-dashboard-token.ts b/src/app-bridge/use-dashboard-token.ts new file mode 100644 index 0000000..d2cbcc6 --- /dev/null +++ b/src/app-bridge/use-dashboard-token.ts @@ -0,0 +1,49 @@ +import debugPkg from "debug"; +import * as jose from "jose"; +import { useMemo } from "react"; + +import { useAppBridge } from "./app-bridge-provider"; + +export interface DashboardTokenPayload extends jose.JWTPayload { + app: string; +} + +export interface DashboardTokenProps { + isTokenValid: boolean; + hasAppToken: boolean; + tokenClaims: DashboardTokenPayload | null; +} + +const debug = debugPkg.debug("app-sdk:AppBridge:useDashboardToken"); + +export const useDashboardToken = (): DashboardTokenProps => { + const { appBridgeState } = useAppBridge(); + + const tokenClaims = useMemo(() => { + try { + if (appBridgeState?.token) { + debug("Trying to decode JWT token from dashboard"); + return jose.decodeJwt(appBridgeState?.token) as DashboardTokenPayload; + } + } catch (e) { + debug("Failed decoding JWT token"); + console.error(e); + } + return null; + }, [appBridgeState?.token]); + + if (tokenClaims && !tokenClaims.iss) { + console.error(` + "iss" not found in decoded token claims. Ensure Saleor has domain assigned + Check documentation for more details + https://docs.saleor.io/docs/3.x/dashboard/configuration/site#general-information`); + } + + const isTokenValid = tokenClaims ? tokenClaims.iss === appBridgeState?.domain : false; + + return { + isTokenValid, + tokenClaims, + hasAppToken: Boolean(appBridgeState?.token), + }; +}; diff --git a/src/app-bridge/with-authorization.tsx b/src/app-bridge/with-authorization.tsx new file mode 100644 index 0000000..e087ab5 --- /dev/null +++ b/src/app-bridge/with-authorization.tsx @@ -0,0 +1,71 @@ +import { NextPage } from "next"; +import * as React from "react"; +import { PropsWithChildren, ReactNode } from "react"; + +import { isInIframe, useIsMounted } from "../util"; +import { useDashboardToken } from "./use-dashboard-token"; + +function SimpleError({ children }: PropsWithChildren<{}>) { + return ( +
+

{children}

+
+ ); +} + +type Props = { + unmounted?: ReactNode; + notIframe?: ReactNode; + noDashboardToken?: ReactNode; + dashboardTokenInvalid?: ReactNode; +}; + +const defaultProps: Props = { + dashboardTokenInvalid: Dashboard token is invalid, + noDashboardToken: Dashboard token doesn"t exist, + notIframe: The view can only be displayed inside iframe., + unmounted:

Loading

, +}; + +/** + * Most likely, views from your app will be only accessibly inside Dashboard iframe. + * This HOC can be used to handle all checks, with default messages included. + * Each error screen can be passed into HOC factory + * + * If screen can be accessible outside Dashboard - omit this HOC on this page + * */ +export const withAuthorization = + (props: Props = defaultProps) => + >( + BaseComponent: React.FunctionComponent + ) => { + const { dashboardTokenInvalid, noDashboardToken, notIframe, unmounted } = { + ...defaultProps, + ...props, + }; + + function AuthorizedPage(innerProps: BaseProps) { + const mounted = useIsMounted(); + const { isTokenValid, hasAppToken } = useDashboardToken(); + + if (!mounted) { + return unmounted; + } + + if (!isInIframe()) { + return notIframe; + } + + if (!hasAppToken) { + return noDashboardToken; + } + + if (!isTokenValid) { + return dashboardTokenInvalid; + } + + return ; + } + + return AuthorizedPage; + }; diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..deacb27 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,2 @@ +export * from "./is-in-iframe"; +export * from "./use-is-mounted"; diff --git a/src/util/is-in-iframe.ts b/src/util/is-in-iframe.ts new file mode 100644 index 0000000..6e900d1 --- /dev/null +++ b/src/util/is-in-iframe.ts @@ -0,0 +1,11 @@ +export const isInIframe = () => { + if (!document || !window) { + throw new Error("isInIframe should be called only in browser"); + } + + try { + return document.location !== window.parent.location; + } catch (e) { + return false; + } +}; diff --git a/src/util/use-is-mounted.ts b/src/util/use-is-mounted.ts new file mode 100644 index 0000000..dfb473b --- /dev/null +++ b/src/util/use-is-mounted.ts @@ -0,0 +1,8 @@ +import { useEffect, useState } from "react"; + +export const useIsMounted = (): boolean => { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + return mounted; +};