From fd07c3403fe5b4f6de5fb5e983120e29cd16d0f4 Mon Sep 17 00:00:00 2001 From: rabbitkiller <243249439@qq.com> Date: Tue, 11 Mar 2025 22:50:55 +0800 Subject: [PATCH] feat(solid): add solid.js logto sdk --- packages/solid-sample/index.html | 13 + packages/solid-sample/package.json | 22 ++ packages/solid-sample/public/favicon.ico | Bin 0 -> 664 bytes packages/solid-sample/src/App.tsx | 34 ++ packages/solid-sample/src/app.css | 4 + packages/solid-sample/src/consts/index.ts | 7 + packages/solid-sample/src/index.tsx | 7 + packages/solid-sample/src/pages/Callback.tsx | 15 + packages/solid-sample/src/pages/Home.tsx | 77 +++++ .../solid-sample/src/pages/Organizations.tsx | 44 +++ .../src/pages/ProtectedResource.tsx | 27 ++ packages/solid-sample/tsconfig.json | 26 ++ packages/solid-sample/vite.config.ts | 25 ++ packages/solid/package.json | 46 +++ packages/solid/rollup.config.js | 7 + packages/solid/src/context.tsx | 49 +++ packages/solid/src/hooks/index.test.tsx | 295 ++++++++++++++++++ packages/solid/src/hooks/index.ts | 136 ++++++++ packages/solid/src/index.ts | 30 ++ packages/solid/src/provider.tsx | 61 ++++ packages/solid/tsconfig.json | 26 ++ 21 files changed, 951 insertions(+) create mode 100644 packages/solid-sample/index.html create mode 100644 packages/solid-sample/package.json create mode 100644 packages/solid-sample/public/favicon.ico create mode 100644 packages/solid-sample/src/App.tsx create mode 100644 packages/solid-sample/src/app.css create mode 100644 packages/solid-sample/src/consts/index.ts create mode 100644 packages/solid-sample/src/index.tsx create mode 100644 packages/solid-sample/src/pages/Callback.tsx create mode 100644 packages/solid-sample/src/pages/Home.tsx create mode 100644 packages/solid-sample/src/pages/Organizations.tsx create mode 100644 packages/solid-sample/src/pages/ProtectedResource.tsx create mode 100644 packages/solid-sample/tsconfig.json create mode 100644 packages/solid-sample/vite.config.ts create mode 100644 packages/solid/package.json create mode 100644 packages/solid/rollup.config.js create mode 100644 packages/solid/src/context.tsx create mode 100644 packages/solid/src/hooks/index.test.tsx create mode 100644 packages/solid/src/hooks/index.ts create mode 100644 packages/solid/src/index.ts create mode 100644 packages/solid/src/provider.tsx create mode 100644 packages/solid/tsconfig.json diff --git a/packages/solid-sample/index.html b/packages/solid-sample/index.html new file mode 100644 index 000000000..41a6dced2 --- /dev/null +++ b/packages/solid-sample/index.html @@ -0,0 +1,13 @@ + + + + + + + Logto Solid Sample + + +
+ + + diff --git a/packages/solid-sample/package.json b/packages/solid-sample/package.json new file mode 100644 index 000000000..16988b328 --- /dev/null +++ b/packages/solid-sample/package.json @@ -0,0 +1,22 @@ +{ + "name": "@logto/solid-sample", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "vite" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@logto/solid": "workspace:^", + "solid-js": "^1.9.5", + "@solidjs/router": "^0.15.3" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^6.0.9", + "vite-plugin-solid": "^2.11.2" + } +} diff --git a/packages/solid-sample/public/favicon.ico b/packages/solid-sample/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fb282da0719ef6ab4c1732df93be6216b0d85520 GIT binary patch literal 664 zcmV;J0%!e+P)m9ebk1R zejT~~6f_`?;`cEd!+`7(hw@%%2;?RN8gX-L?z6cM( zKoG@&w+0}f@Pfvwc+deid)qgE!L$ENKYjViZC_Zcr>L(`2oXUT8f0mRQ(6-=HN_Ai zeBBEz3WP+1Cw`m!49Wf!MnZzp5bH8VkR~BcJ1s-j90TAS2Yo4j!J|KodxYR%3Numw zA?gq6e`5@!W~F$_De3yt&uspo&2yLb$(NwcPPI-4LGc!}HdY%jfq@AFs8LiZ4k(p} zZ!c9o+qbWYs-Mg zgdyTALzJX&7QXHdI_DPTFL33;w}88{e6Zk)MX0kN{3DX9uz#O_L58&XRH$Nvvu;fO zf&)7@?C~$z1K<>j0ga$$MIg+5xN;eQ?1-CA=`^Y169@Ab6!vcaNP=hxfKN%@Ly^R* zK1iv*s1Yl6_dVyz8>ZqYhz6J4|3fQ@2LQeX@^%W(B~8>=MoEmBEGGD1;gHXlpX>!W ym)!leA2L@`cpb^hy)P75=I!`pBYxP7<2VfQ3j76qLgzIA0000 import("./pages/Home")) }, + { path: '/callback', component: lazy(() => import("./pages/Callback")) }, + { path: '/protected', component: lazy(() => import("./pages/ProtectedResource")) }, + { path: '/protected/organizations', component: lazy(() => import("./pages/Organizations")) }, +]; + +export function App() { + const config: LogtoConfig = { + appId, + endpoint, + scopes: [ + UserScope.Email, + UserScope.Phone, + UserScope.CustomData, + UserScope.Identities, + UserScope.Organizations, + ], + }; + return ( + +
+ {routes} +
+
+ ); +} diff --git a/packages/solid-sample/src/app.css b/packages/solid-sample/src/app.css new file mode 100644 index 000000000..a2e219ad9 --- /dev/null +++ b/packages/solid-sample/src/app.css @@ -0,0 +1,4 @@ +body { + margin: 0; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; +} diff --git a/packages/solid-sample/src/consts/index.ts b/packages/solid-sample/src/consts/index.ts new file mode 100644 index 000000000..4101d62ec --- /dev/null +++ b/packages/solid-sample/src/consts/index.ts @@ -0,0 +1,7 @@ +export const baseUrl = window.location.origin; +export const redirectUrl = `${baseUrl}/callback`; + +export const appId = 'hi9w1eijf2nlnuz88xjt6'; // Register the sample app in Logto dashboard +export const endpoint = 'http://localhost:3001'; // Replace with your own Logto endpoint +// export const resource = "your-resource-identifier"; // Replace with your own API resource identifier +export const resource = ""; // Replace with your own API resource identifier diff --git a/packages/solid-sample/src/index.tsx b/packages/solid-sample/src/index.tsx new file mode 100644 index 000000000..57b81d70e --- /dev/null +++ b/packages/solid-sample/src/index.tsx @@ -0,0 +1,7 @@ +import { render } from "solid-js/web"; +import { App } from "./App"; + +const root = document.getElementById("app"); +if (root) { + render(() => , root); +} diff --git a/packages/solid-sample/src/pages/Callback.tsx b/packages/solid-sample/src/pages/Callback.tsx new file mode 100644 index 000000000..c747f4a61 --- /dev/null +++ b/packages/solid-sample/src/pages/Callback.tsx @@ -0,0 +1,15 @@ +import { useHandleSignInCallback } from '@logto/solid'; +import { useNavigate } from '@solidjs/router'; +import { Show } from 'solid-js'; + +export default function Callback() { + const navigate = useNavigate(); + const { isLoading } = useHandleSignInCallback(() => { + console.log("???") + navigate('/'); + }); + + return +

Redirecting...

+
+}; diff --git a/packages/solid-sample/src/pages/Home.tsx b/packages/solid-sample/src/pages/Home.tsx new file mode 100644 index 000000000..0512b5777 --- /dev/null +++ b/packages/solid-sample/src/pages/Home.tsx @@ -0,0 +1,77 @@ +import { useLogto, type UserInfoResponse } from '@logto/solid'; +import { createSignal, Show } from "solid-js"; +import { baseUrl, redirectUrl } from '../consts'; + +const Home = () => { + const {isAuthenticated, signIn, signOut, fetchUserInfo} = useLogto(); + const [user, setUser] = createSignal(); + + (async () => { + if (isAuthenticated()) { + const userInfo = await fetchUserInfo(); + setUser(userInfo); + } + })(); + + return ( +
+

Logto Solid sample

+ + + + + + + + + + + + + + + + + {Object.entries(user()).map(([key, value]) => ( + + + + + ))} + +
NameValue
{key}{typeof value === 'string' ? value : JSON.stringify(value)}
+ +
+
+ ); +}; + +export default Home; diff --git a/packages/solid-sample/src/pages/Organizations.tsx b/packages/solid-sample/src/pages/Organizations.tsx new file mode 100644 index 000000000..7ea6bdb13 --- /dev/null +++ b/packages/solid-sample/src/pages/Organizations.tsx @@ -0,0 +1,44 @@ +import { useLogto } from '@logto/solid'; +import { createSignal, onMount } from 'solid-js'; + +const Organizations = () => { + const { getOrganizationToken, getOrganizationTokenClaims, getIdTokenClaims } = useLogto(); + const [organizationIds, setOrganizationIds] = createSignal(); + + onMount(() => { + (async () => { + const claims = await getIdTokenClaims(); + + console.log('ID token claims', claims); + setOrganizationIds(claims?.organizations); + })(); + }); + + return ( +
+

Organizations

+ Go back + {organizationIds()?.length === 0 &&

No organization memberships found.

} +
    + {organizationIds()?.map((organizationId) => { + return ( +
  • + {organizationId} + +
  • + ); + })} +
+
+ ); +}; + +export default Organizations; diff --git a/packages/solid-sample/src/pages/ProtectedResource.tsx b/packages/solid-sample/src/pages/ProtectedResource.tsx new file mode 100644 index 000000000..92bb90145 --- /dev/null +++ b/packages/solid-sample/src/pages/ProtectedResource.tsx @@ -0,0 +1,27 @@ +import { useLogto } from "@logto/solid"; +import { createSignal, Show } from "solid-js"; +import { resource } from "../consts"; + +const ProtectedResource = () => { + const { isAuthenticated, isLoading, signIn, getAccessToken } = useLogto(); + const [accessToken, setAccessToken] = createSignal(); + const handleClick = async () => { + const token = await getAccessToken(resource); + setAccessToken(token); + }; + return ( +
+ Go back + +

Protected resource is only visible after sign-in.

+
+ + + Access token: {accessToken()} + +
+) + ; +}; + +export default ProtectedResource; diff --git a/packages/solid-sample/tsconfig.json b/packages/solid-sample/tsconfig.json new file mode 100644 index 000000000..7430ee9f7 --- /dev/null +++ b/packages/solid-sample/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "esnext", + "esModuleInterop": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": false, + "outDir": "lib", + "types": ["solid-js"] + }, + "include": [ + "src", + "vitest.config.ts" + ] +} diff --git a/packages/solid-sample/vite.config.ts b/packages/solid-sample/vite.config.ts new file mode 100644 index 000000000..52606f451 --- /dev/null +++ b/packages/solid-sample/vite.config.ts @@ -0,0 +1,25 @@ +import { fileURLToPath, URL } from "url"; + +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [solid()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + optimizeDeps: { + include: ["@logto/solid"], + }, + build: { + // commonjsOptions: { + // include: [/vue/, /node_modules/], + // }, + }, + server: { + port: 3000, + }, +}); diff --git a/packages/solid/package.json b/packages/solid/package.json new file mode 100644 index 000000000..90e393f4f --- /dev/null +++ b/packages/solid/package.json @@ -0,0 +1,46 @@ +{ + "name": "@logto/solid", + "version": "0.0.0", + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/source/index.js" + }, + "files": [ + "dist" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/logto-io/js.git", + "directory": "packages/solid" + }, + "scripts": { + "dev:tsc": "tsc -p tsconfig.build.json -w --preserveWatchOutput", + "precommit": "lint-staged", + "check": "tsc --noEmit", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c", + "lint": "eslint --ext .ts --ext .tsx src", + "test": "vitest", + "test:coverage": "vitest --silent --coverage", + "prepack": "pnpm build && pnpm test" + }, + "dependencies": { + "@logto/browser": "workspace:^", + "@silverhand/essentials": "^2.9.2", + "solid-js": "^1.9.5" + }, + "devDependencies": { + "rollup-preset-solid": "^3.0.0", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "solid-js": ">=1.9.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/solid/rollup.config.js b/packages/solid/rollup.config.js new file mode 100644 index 000000000..8f397aec2 --- /dev/null +++ b/packages/solid/rollup.config.js @@ -0,0 +1,7 @@ +import withSolid from "rollup-preset-solid"; + +export default withSolid({ + input: "src/index.ts", + targets: ["esm"], +}); + diff --git a/packages/solid/src/context.tsx b/packages/solid/src/context.tsx new file mode 100644 index 000000000..22de2adc0 --- /dev/null +++ b/packages/solid/src/context.tsx @@ -0,0 +1,49 @@ +import type LogtoClient from '@logto/browser'; +import {Accessor, createContext} from 'solid-js'; + +export type LogtoContextProps = { + /** The underlying LogtoClient instance (from `@logto/browser`). */ + logtoClient: Accessor; + /** Whether the user is authenticated or not. */ + isAuthenticated: Accessor; + /** Whether the context has any pending requests. It will be `true` if there is at least one request pending. */ + isLoading: Accessor; + /** The error that occurred during the last request. If there was no error, this will be `undefined`. */ + error: Accessor; + /** Sets the authentication state. */ + setIsAuthenticated: (isAuthenticated: boolean) => void; + /** + * Sets the loading state. + * + * @remarks + * Instead of directly setting the boolean value, this function will increment or decrement the + * loading count. + * + * - If the `state` is `true`, the loading count will be incremented by 1. + * - If the `state` is `false`, the loading count will be decremented by 1. If the loading count + * is already 0, it will be set to 0. + */ + setLoading: (state: boolean) => void; + /** Sets the error state. To clear the error, set this to `undefined`. */ + setError: (error: unknown, fallbackErrorMessage?: string | undefined) => void; +}; + +export const throwContextError = (): never => { + throw new Error('Must be used inside context.'); +}; + +/** + * The context for the LogtoProvider. + * + * @remarks + * Instead of using this context directly, in most cases you should use the `useLogto` hook. + */ +export const LogtoContext = createContext({ + logtoClient: () => undefined, + isAuthenticated: () => false, + isLoading: () => false, + error: () => undefined, + setIsAuthenticated: throwContextError, + setLoading: throwContextError, + setError: throwContextError, +}); diff --git a/packages/solid/src/hooks/index.test.tsx b/packages/solid/src/hooks/index.test.tsx new file mode 100644 index 000000000..b07163cb0 --- /dev/null +++ b/packages/solid/src/hooks/index.test.tsx @@ -0,0 +1,295 @@ +import LogtoClient from '@logto/browser'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useContext, type ReactNode, useEffect } from 'react'; + +import { LogtoContext } from '../context.js'; +import { LogtoProvider } from '../provider.js'; + +import { useHandleSignInCallback, useLogto } from './index.js'; + +const isAuthenticated = vi.fn(async () => false); +const isSignInRedirected = vi.fn(async () => false); +const handleSignInCallback = vi.fn().mockResolvedValue(undefined); +const getAccessToken = vi.fn(); +const signIn = vi.fn(); + +vi.mock('@logto/browser', () => { + return { + default: vi.fn().mockImplementation(() => { + return { + isAuthenticated, + isSignInRedirected, + handleSignInCallback, + getRefreshToken: vi.fn(), + getAccessToken, + getAccessTokenClaims: vi.fn(), + getOrganizationToken: vi.fn(), + getOrganizationTokenClaims: vi.fn(), + getIdToken: vi.fn(), + getIdTokenClaims: vi.fn(), + signIn, + signOut: vi.fn(), + fetchUserInfo: vi.fn(), + clearAccessToken: vi.fn(), + clearAllTokens: vi.fn(), + } satisfies Partial; + }), + }; +}); + +const endpoint = 'https://logto.dev'; +const appId = 'foo'; + +const createHookWrapper = + () => + ({ children }: { children?: ReactNode }) => ( + {children} + ); + +const HasError = ({ children }: { children?: ReactNode }) => { + const { error, setError } = useContext(LogtoContext); + useEffect(() => { + setError(new Error('Oops')); + }, [setError]); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{error ? children : null}; +}; + +const AlwaysLoading = ({ children }: { children?: ReactNode }) => { + const { setIsLoading } = useContext(LogtoContext); + useEffect(() => { + setIsLoading(true); // Simulate always loading + }, [setIsLoading]); + + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +}; + +describe('useLogto', () => { + afterEach(() => { + vi.clearAllMocks(); + handleSignInCallback.mockRestore(); + }); + + it('should throw without using context provider', () => { + expect(() => renderHook(useLogto)).toThrow(); + }); + + it('should call LogtoClient constructor on init', async () => { + await act(async () => { + renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + }); + + expect(LogtoClient).toHaveBeenCalledWith({ endpoint, appId }, false); + }); + + it('should return LogtoClient property methods', async () => { + const { result } = renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + + await waitFor(() => { + const { + signIn, + signOut, + fetchUserInfo, + getAccessToken, + getIdTokenClaims, + clearAccessToken, + clearAllTokens, + error, + } = result.current; + + expect(error).toBeUndefined(); + expect(signIn).toBeDefined(); + expect(signOut).toBeDefined(); + expect(fetchUserInfo).toBeDefined(); + expect(getAccessToken).toBeDefined(); + expect(getIdTokenClaims).toBeDefined(); + expect(clearAccessToken).toBeDefined(); + expect(clearAllTokens).toBeDefined(); + }); + }); + + it('should not call `handleSignInCallback` when logtoClient is not found in the context', async () => { + // Mock `isSignInRedirected` to return true for triggering `useEffect` + isSignInRedirected.mockResolvedValueOnce(true); + const { result } = renderHook(useHandleSignInCallback); + + await waitFor(() => { + // LogtoClient is initialized + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + }); + + expect(handleSignInCallback).not.toHaveBeenCalled(); + isSignInRedirected.mockRestore(); + }); + + it('should not call `handleSignInCallback` when it is not in callback url', async () => { + const { result } = renderHook(useHandleSignInCallback, { + wrapper: createHookWrapper(), + }); + + await waitFor(() => { + // LogtoClient is initialized + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + }); + + expect(handleSignInCallback).not.toHaveBeenCalled(); + }); + + it('should not call `handleSignInCallback` when it is authenticated', async () => { + isSignInRedirected.mockResolvedValueOnce(true); + isAuthenticated.mockResolvedValueOnce(true); + + await act(async () => { + renderHook(useHandleSignInCallback, { + wrapper: createHookWrapper(), + }); + }); + + expect(handleSignInCallback).not.toHaveBeenCalled(); + }); + + it('should call `handleSignInCallback` when navigated back to predefined callback url', async () => { + isSignInRedirected.mockResolvedValueOnce(true); + + const { result } = renderHook(useHandleSignInCallback, { + wrapper: createHookWrapper(), + }); + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + expect(handleSignInCallback).toHaveBeenCalledTimes(1); + }); + + it('should call `handleSignInCallback` only once even if it fails internally', async () => { + isSignInRedirected.mockResolvedValueOnce(true); + handleSignInCallback.mockRejectedValueOnce(new Error('Oops')); + + const { result } = renderHook(useHandleSignInCallback, { + wrapper: createHookWrapper(), + }); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + }); + expect(handleSignInCallback).toHaveBeenCalledTimes(1); + }); + + it('should not call `handleSignInCallback` when it is loading', async () => { + isSignInRedirected.mockResolvedValueOnce(true); + + const { result } = renderHook(useHandleSignInCallback, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + // Give some time for side effects to be triggered + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + expect(handleSignInCallback).toHaveBeenCalledTimes(0); + }); + + it('should not call `handleSignInCallback` when `useLogto` has error', async () => { + const { result } = renderHook(useHandleSignInCallback, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await waitFor(() => { + expect(result.current.error).toBeDefined(); + }); + // Give some time for side effects to be triggered + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + expect(handleSignInCallback).toHaveBeenCalledTimes(0); + }); + + it('should return error when getAccessToken fails', async () => { + const { result } = renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + + await act(async () => { + getAccessToken.mockRejectedValueOnce(new Error('not authenticated')); + await result.current.getAccessToken(); + }); + await waitFor(() => { + expect(result.current.error).not.toBeUndefined(); + expect(result.current.error?.message).toBe('not authenticated'); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should use fallback error message when getAccessToken fails', async () => { + const { result } = renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + + await act(async () => { + getAccessToken.mockRejectedValueOnce('not authenticated'); + await result.current.getAccessToken('foo'); + }); + await waitFor(() => { + expect(result.current.error).not.toBeUndefined(); + expect(result.current.error?.message).toBe( + 'Unexpected error occurred while calling bound spy.' + ); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should call the inner LogtoClient method when the hook method is called', async () => { + const { result } = renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + + await act(async () => { + await result.current.signIn('foo'); + }); + + await waitFor(() => { + expect(signIn).toHaveBeenCalledTimes(1); + expect(signIn).toHaveBeenCalledWith('foo'); + expect(result.current.error).toBeUndefined(); + // `signIn` disables resetting loading state + expect(result.current.isLoading).toBe(true); + }); + }); + + it('should be able to call the inner LogtoClient method overload signature', async () => { + const { result } = renderHook(useLogto, { + wrapper: createHookWrapper(), + }); + + await act(async () => { + await result.current.signIn({ redirectUri: 'foo' }); + }); + + await waitFor(() => { + expect(signIn).toHaveBeenCalledTimes(1); + expect(signIn).toHaveBeenCalledWith({ redirectUri: 'foo' }); + expect(result.current.error).toBeUndefined(); + // `signIn` disables resetting loading state + expect(result.current.isLoading).toBe(true); + }); + }); +}); diff --git a/packages/solid/src/hooks/index.ts b/packages/solid/src/hooks/index.ts new file mode 100644 index 000000000..df10b7f9e --- /dev/null +++ b/packages/solid/src/hooks/index.ts @@ -0,0 +1,136 @@ +import type LogtoClient from '@logto/browser'; +import { type Optional, trySafe } from '@silverhand/essentials'; +import { Accessor, createEffect, useContext } from 'solid-js'; + +import { LogtoContext, throwContextError } from '../context'; + +type OptionalPromiseReturn = { + [K in keyof T]: T[K] extends (...args: infer A) => Promise + ? (...args: A) => Promise> + : T[K]; +}; + +type Logto = { + isAuthenticated: Accessor; + isLoading: Accessor; + error: Accessor; +} & OptionalPromiseReturn< + Pick< + LogtoClient, + | 'getRefreshToken' + | 'getAccessToken' + | 'getAccessTokenClaims' + | 'getOrganizationToken' + | 'getOrganizationTokenClaims' + | 'getIdToken' + | 'getIdTokenClaims' + | 'signOut' + | 'fetchUserInfo' + | 'clearAccessToken' + | 'clearAllTokens' + > +> & + // Manually pick the method with overloads since TypeScript cannot infer the correct type. + Pick; + +const useErrorHandler = () => { + const {setError} = useContext(LogtoContext); + + function handleError(error: unknown, fallbackErrorMessage?: string) { + if (error instanceof Error) { + setError(error); + } else if (fallbackErrorMessage) { + setError(new Error(fallbackErrorMessage)); + } + console.error(error); + } + + return {handleError}; +}; + +const useHandleSignInCallback = (callback?: () => void) => { + const {logtoClient, isAuthenticated, error, setIsAuthenticated, isLoading, setLoading, setError} = + useContext(LogtoContext); + + createEffect(() => { + const client = logtoClient(); + if (!client || isLoading() || error()) { + return; + } + + (async () => { + const currentPageUrl = window.location.href; + const isRedirected = await client.isSignInRedirected(currentPageUrl); + + if (!isAuthenticated() && isRedirected) { + setLoading(true); + await trySafe( + async () => { + await client.handleSignInCallback(currentPageUrl); + setIsAuthenticated(true); + callback?.(); + }, + (error) => { + setError(error, 'Unexpected error occurred while handling sign in callback.'); + } + ); + setLoading(false); + } + })(); + }) + + return { + isLoading, + isAuthenticated, + error, + }; +}; + +const useLogto = (): Logto => { + const {logtoClient, isAuthenticated, error, isLoading, setLoading, setError} = useContext(LogtoContext); + + const client = logtoClient() ?? throwContextError(); + + const proxy = ( + run: (...args: T) => Promise, + resetLoadingState = true + ) => { + return async (...args: T): Promise> => { + try { + setLoading(true); + return await run(...args); + } catch (error: unknown) { + setError(error, `Unexpected error occurred while calling ${run.name}.`); + } finally { + if (resetLoadingState) { + setLoading(false); + } + } + }; + }; + + return { + isAuthenticated, + isLoading, + error, + getRefreshToken: proxy(client.getRefreshToken.bind(client)), + getAccessToken: proxy(client.getAccessToken.bind(client)), + getAccessTokenClaims: proxy(client.getAccessTokenClaims.bind(client)), + getOrganizationToken: proxy(client.getOrganizationToken.bind(client)), + getOrganizationTokenClaims: proxy(client.getOrganizationTokenClaims.bind(client)), + getIdToken: proxy(client.getIdToken.bind(client)), + getIdTokenClaims: proxy(client.getIdTokenClaims.bind(client)), + // eslint-disable-next-line no-restricted-syntax -- TypeScript cannot infer the correct type. + signIn: proxy(client.signIn.bind(client), false) as LogtoClient['signIn'], + // We deliberately do NOT set isAuthenticated to false in the function below, because the app state + // may change immediately even before navigating to the oidc end session endpoint, which might cause + // rendering problems. + // Moreover, since the location will be redirected, the isAuthenticated state will not matter any more. + signOut: proxy(client.signOut.bind(client)), + fetchUserInfo: proxy(client.fetchUserInfo.bind(client)), + clearAccessToken: proxy(client.clearAccessToken.bind(client)), + clearAllTokens: proxy(client.clearAllTokens.bind(client)), + }; +}; + +export { useLogto, useHandleSignInCallback }; diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts new file mode 100644 index 000000000..43d7bcdf8 --- /dev/null +++ b/packages/solid/src/index.ts @@ -0,0 +1,30 @@ +export type { LogtoContextProps, LogtoContext } from './context.js'; + +export type { + LogtoConfig, + IdTokenClaims, + UserInfoResponse, + LogtoErrorCode, + LogtoClientErrorCode, + InteractionMode, + AccessTokenClaims, +} from '@logto/browser'; + +export { + LogtoError, + LogtoRequestError, + LogtoClientError, + OidcError, + Prompt, + ReservedScope, + ReservedResource, + UserScope, + organizationUrnPrefix, + buildOrganizationUrn, + getOrganizationIdFromUrn, + PersistKey, +} from '@logto/browser'; + +export * from './provider'; + +export { useLogto, useHandleSignInCallback } from './hooks/index'; diff --git a/packages/solid/src/provider.tsx b/packages/solid/src/provider.tsx new file mode 100644 index 000000000..2b4b29140 --- /dev/null +++ b/packages/solid/src/provider.tsx @@ -0,0 +1,61 @@ +import LogtoClient, { type LogtoConfig } from '@logto/browser'; +import { createMemo, createSignal, JSX } from "solid-js"; +import { LogtoContext } from './context'; + +export type LogtoProviderProps = { + config: LogtoConfig; + /** + * Whether to enable cache for well-known data. Use sessionStorage by default. + * @default false + */ + // eslint-disable-next-line react/boolean-prop-naming + unstable_enableCache?: boolean; + LogtoClientClass?: typeof LogtoClient; + children: JSX.Element; +}; + +export const LogtoProvider = (props: LogtoProviderProps) => { + const LogtoClientClass = props.LogtoClientClass || LogtoClient; + const [loadingCount, setLoadingCount] = createSignal(1); + + const logtoClient = createMemo(() => { + return new LogtoClientClass(props.config, props.unstable_enableCache) + }) + const [isAuthenticated, setIsAuthenticated] = createSignal(false); + const [error, setError] = createSignal(); + + const isLoading = () => { + return loadingCount() > 0; + } + const setLoading = (isLoading: boolean) => { + if (isLoading) { + setLoadingCount(loadingCount() + 1); + } else { + setLoadingCount(Math.max(0, loadingCount() - 1)); + } + }; + + (async () => { + const isAuthenticated = await logtoClient().isAuthenticated(); + + setIsAuthenticated(isAuthenticated); + setLoading(false); + })(); + + return { + if (_error instanceof Error) { + setError(_error); + } else if (fallbackErrorMessage) { + setError(new Error(fallbackErrorMessage)); + } + console.error(error); + }, + }}>{props.children}; +}; diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json new file mode 100644 index 000000000..7430ee9f7 --- /dev/null +++ b/packages/solid/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "esnext", + "esModuleInterop": true, + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": false, + "outDir": "lib", + "types": ["solid-js"] + }, + "include": [ + "src", + "vitest.config.ts" + ] +}