From 978c9e5cf217ffb73337f2db2046239e62201f5c Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Mon, 9 Dec 2024 12:14:55 +0100 Subject: [PATCH] feat: add getServerSession and SessionProvider --- eslint.config.mjs | 7 +- package-lock.json | 59 +++++-- .../api-report/elements-react-client.api.json | 160 ++++++++++++++++-- .../api-report/elements-react-client.api.md | 27 ++- packages/elements-react/src/client/config.ts | 83 +++++++++ .../src/client/frontendClient.ts | 20 ++- packages/elements-react/src/client/index.ts | 6 + .../src/client/session-provider.tsx | 119 +++++++++++++ .../src/client/useSession.spec.tsx | 72 +++++--- .../elements-react/src/client/useSession.ts | 87 +++------- .../theme/default/components/ui/user-menu.tsx | 2 +- .../default/utils/__tests__/user.spec.ts | 2 +- .../src/theme/default/utils/user.ts | 2 +- packages/elements-react/tsup.config.ts | 2 +- packages/nextjs/package.json | 3 +- packages/nextjs/src/app/index.ts | 2 + packages/nextjs/src/app/session.ts | 15 ++ packages/nextjs/src/app/settings.ts | 53 ++++++ packages/nextjs/src/app/utils.ts | 2 +- packages/nextjs/src/types.ts | 7 +- packages/nextjs/src/utils/cookie.ts | 2 +- packages/nextjs/src/utils/rewrite.ts | 1 + packages/nextjs/tsconfig.lib.json | 5 +- 23 files changed, 597 insertions(+), 141 deletions(-) create mode 100644 packages/elements-react/src/client/config.ts create mode 100644 packages/elements-react/src/client/session-provider.tsx create mode 100644 packages/nextjs/src/app/session.ts create mode 100644 packages/nextjs/src/app/settings.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index b7f9f7b74..ed92fb90f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,16 +17,13 @@ const config = tseslint.config([ "!.storybook", "**/dist/**", "**/storybook-static/**", - "babel.config.js", - "eslint.config.mjs", + "**/*.config.{js,mjs,cjs,ts}", "**/playwright-report/**", "examples/nextjs-spa/**", // This project is not maintained - "**/playwright.config.{js,ts}", - "**/playwright-ct.config.ts", - "**/jest.config.{js,ts}", "**/jest.preset.{cjs,ts}", "playwright/**", "**/tsup.config.{js,ts}", + "**/.next/**", ], }, eslint.configs.recommended, diff --git a/package-lock.json b/package-lock.json index cc47ab895..d23dfec06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,46 @@ "npm": ">=8.11.0" } }, + "examples/nextjs-app-router": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@ory/nextjs": "^0.0.1", + "next": "^15.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.15", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } + }, + "examples/nextjs-pages-router": { + "version": "0.1.0", + "extraneous": true, + "dependencies": { + "@ory/nextjs": "^0.0.1", + "next": "^15.0.3", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.15", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5" + } + }, "examples/nextjs-spa": { "version": "0.0.0", "dependencies": { @@ -12695,13 +12735,6 @@ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", "license": "MIT" }, - "node_modules/@types/psl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/psl/-/psl-1.1.3.tgz", - "integrity": "sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.9.15", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", @@ -27306,12 +27339,15 @@ "license": "MIT" }, "node_modules/psl": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.14.0.tgz", - "integrity": "sha512-Syk1bnf6fRZ9wQs03AtKJHcM12cKbOLo9L8JtCCdYj5/DTsHmTyXM4BK5ouWeG2P6kZ4nmFvuNTdtaqfobCOCg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "license": "MIT", "dependencies": { "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" } }, "node_modules/punycode": { @@ -34086,12 +34122,11 @@ "dependencies": { "@ory/client-fetch": "^1.15.6", "cookie": "^1.0.1", - "psl": "^1.10.0", + "psl": "^1.15.0", "set-cookie-parser": "^2.7.1" }, "devDependencies": { "@types/cookie": "^0.6.0", - "@types/psl": "^1.1.3", "@types/set-cookie-parser": "^2.4.10", "babel-jest": "^29.7.0", "jest-esm-transformer": "^1.0.0", diff --git a/packages/elements-react/api-report/elements-react-client.api.json b/packages/elements-react/api-report/elements-react-client.api.json index ca80b6125..a91355776 100644 --- a/packages/elements-react/api-report/elements-react-client.api.json +++ b/packages/elements-react/api-report/elements-react-client.api.json @@ -173,53 +173,181 @@ "preserveMemberOrder": false, "members": [ { - "kind": "Function", - "canonicalReference": "@ory/elements-react!useSession:function(1)", - "docComment": "/**\n * A hook to get the current session from the Ory Network.\n *\n * Usage:\n * ```ts\n * const { session, error, isLoading } = useSession()\n * ```\n *\n * @returns The current session, error and loading state.\n */\n", + "kind": "TypeAlias", + "canonicalReference": "@ory/elements-react!SessionContextData:type", + "docComment": "", "excerptTokens": [ { "kind": "Content", - "text": "useSession: (config?: " + "text": "type SessionContextData = " }, { "kind": "Content", - "text": "{\n sdk: {\n url: string;\n };\n}" + "text": "{\n isLoading: boolean;\n isInitialLoading: boolean;\n session: " + }, + { + "kind": "Reference", + "text": "Session", + "canonicalReference": "@ory/client-fetch!Session:interface" }, { "kind": "Content", - "text": ") => " + "text": " | null;\n error?: " + }, + { + "kind": "Reference", + "text": "Error", + "canonicalReference": "!Error:interface" }, { "kind": "Content", - "text": "{\n session: " + "text": ";\n refetch: () => " }, { "kind": "Reference", - "text": "Session", - "canonicalReference": "@ory/client-fetch!Session:interface" + "text": "Promise", + "canonicalReference": "!Promise:interface" + }, + { + "kind": "Content", + "text": ";\n}" }, { "kind": "Content", - "text": " | undefined;\n error: string | undefined;\n isLoading: boolean;\n}" + "text": ";" } ], - "fileUrlPath": "dist/client/useSession.d.ts", + "fileUrlPath": "dist/client/session-provider.d.ts", + "releaseTag": "Public", + "name": "SessionContextData", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 8 + } + }, + { + "kind": "Function", + "canonicalReference": "@ory/elements-react!SessionProvider:function(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function SessionProvider({ " + }, + { + "kind": "Reference", + "text": "session", + "canonicalReference": "@ory/elements-react!~__type#session" + }, + { + "kind": "Content", + "text": ": initialSession, children, baseUrl, }: " + }, + { + "kind": "Reference", + "text": "SessionProviderProps", + "canonicalReference": "@ory/elements-react!SessionProviderProps:type" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "react_jsx_runtime.JSX.Element", + "canonicalReference": "@types/react!JSX.Element:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/client/session-provider.d.ts", "returnTypeTokenRange": { - "startIndex": 3, + "startIndex": 5, "endIndex": 6 }, "releaseTag": "Public", "overloadIndex": 1, "parameters": [ { - "parameterName": "config", + "parameterName": "{ session: initialSession, children, baseUrl, }", "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 + "startIndex": 3, + "endIndex": 4 }, - "isOptional": true + "isOptional": false + } + ], + "name": "SessionProvider" + }, + { + "kind": "TypeAlias", + "canonicalReference": "@ory/elements-react!SessionProviderProps:type", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "type SessionProviderProps = " + }, + { + "kind": "Content", + "text": "{\n session?: " + }, + { + "kind": "Reference", + "text": "Session", + "canonicalReference": "@ory/client-fetch!Session:interface" + }, + { + "kind": "Content", + "text": " | null;\n baseUrl?: string;\n} & " + }, + { + "kind": "Reference", + "text": "React.PropsWithChildren", + "canonicalReference": "@types/react!React.PropsWithChildren:type" + }, + { + "kind": "Content", + "text": ";" + } + ], + "fileUrlPath": "dist/client/session-provider.d.ts", + "releaseTag": "Public", + "name": "SessionProviderProps", + "typeTokenRange": { + "startIndex": 1, + "endIndex": 5 + } + }, + { + "kind": "Function", + "canonicalReference": "@ory/elements-react!useSession:function(1)", + "docComment": "/**\n * A hook to get the current session from the Ory Network.\n *\n * Usage:\n * ```ts\n * const session = useSession()\n *\n * if (session.state == \"loading\") {\n * return
Loading...
\n * }\n *\n * if (session.state == \"authenticated\") {\n * return
Session: {session.session.id}
\n * }\n * ```\n *\n * :::note This is a client-side hook and must be used within a React component. On the server, you can use the getServerSession function from `@ory/nextjs` and hydrate SessionProvider with the session. :::\n *\n * @returns The current session, and error or loading state.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "declare function useSession(): " + }, + { + "kind": "Reference", + "text": "SessionContextData", + "canonicalReference": "@ory/elements-react!SessionContextData:type" + }, + { + "kind": "Content", + "text": ";" } ], + "fileUrlPath": "dist/client/useSession.d.ts", + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [], "name": "useSession" } ] diff --git a/packages/elements-react/api-report/elements-react-client.api.md b/packages/elements-react/api-report/elements-react-client.api.md index 2dd929df2..23c93de22 100644 --- a/packages/elements-react/api-report/elements-react-client.api.md +++ b/packages/elements-react/api-report/elements-react-client.api.md @@ -4,19 +4,30 @@ ```ts +import * as react_jsx_runtime from 'react/jsx-runtime'; import { Session } from '@ory/client-fetch'; -// @public -export const useSession: (config?: { - sdk: { - url: string; - }; -}) => { - session: Session | undefined; - error: string | undefined; +// @public (undocumented) +export type SessionContextData = { isLoading: boolean; + isInitialLoading: boolean; + session: Session | null; + error?: Error; + refetch: () => Promise; }; +// @public (undocumented) +export function SessionProvider({ session: initialSession, children, baseUrl, }: SessionProviderProps): react_jsx_runtime.JSX.Element; + +// @public (undocumented) +export type SessionProviderProps = { + session?: Session | null; + baseUrl?: string; +} & React.PropsWithChildren; + +// @public +export function useSession(): SessionContextData; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/elements-react/src/client/config.ts b/packages/elements-react/src/client/config.ts new file mode 100644 index 000000000..86d1828ca --- /dev/null +++ b/packages/elements-react/src/client/config.ts @@ -0,0 +1,83 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +/** + * This function returns the base URL of the Ory SDK as set by environment variables `NEXT_PUBLIC_ORY_SDK_URL` or `ORY_SDK_URL`. + */ +export function orySdkUrl() { + let baseUrl + + if (process.env.NEXT_PUBLIC_ORY_SDK_URL) { + baseUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL + } + + if (process.env.ORY_SDK_URL) { + baseUrl = process.env._ORY_SDK_URL + } + + if (!baseUrl) { + throw new Error( + "You need to set environment variable `NEXT_PUBLIC_ORY_SDK_URL` or if you don't use Next.js `ORY_SDK_URL` to your Ory Network SDK URL.", + ) + } + + return baseUrl.replace(/\/$/, "") +} + +/** + * This function returns whether the current environment is a production environment. + */ +export const isProduction = ["production", "prod"].includes( + process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "", +) + +/** + * This function returns the Ory SDK URL. If the environment is not production, it tries to guess the SDK URL based on the environment variables, assuming + * that Ory APIs are proxied through the same domain as the application. + * + * Currently, this is only tested for Vercel deployments. + * + * @param options - Options for guessing the SDK URL. + */ +export function guessPotentiallyProxiedOrySdkUrl(options?: { + knownProxiedUrl?: string +}) { + if (isProduction) { + // In production, we use the production custom domain + return orySdkUrl() + } + + if (process.env.VERCEL_ENV) { + // We are in vercel + + // The domain name of the generated deployment URL. Example: *.vercel.app. The value does not include the protocol scheme https://. + // + // This is only available for preview deployments on Vercel. + if (!isProduction && process.env.VERCEL_URL) { + return `https://${process.env.VERCEL_URL}`.replace(/\/$/, "") + } + + // This is sometimes set by the render server. + if (process.env.__NEXT_PRIVATE_ORIGIN) { + return process.env.__NEXT_PRIVATE_ORIGIN.replace(/\/$/, "") + } + } + + // Unable to figure out the SDK URL. Either because we are not using Vercel or because we are on a local machine. + // Let's try to use the window location. + if (typeof window !== "undefined") { + return window.location.origin + } + + if (options?.knownProxiedUrl) { + return options.knownProxiedUrl + } + + // We tried everything. Let's use the SDK URL. + const final = orySdkUrl() + console.warn( + `Unable to determine a suitable SDK URL for setting up the Next.js integration of Ory Elements. Will proceed using default Ory SDK URL "${final}". This is likely not what you want for local development and your authentication and login may not work.`, + ) + + return final +} diff --git a/packages/elements-react/src/client/frontendClient.ts b/packages/elements-react/src/client/frontendClient.ts index 66cd0bb2f..fcf1469b5 100644 --- a/packages/elements-react/src/client/frontendClient.ts +++ b/packages/elements-react/src/client/frontendClient.ts @@ -1,19 +1,31 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 - +"use client" import { Configuration, ConfigurationParameters, FrontendApi, } from "@ory/client-fetch" +import { guessPotentiallyProxiedOrySdkUrl } from "./config" export function frontendClient( - sdkUrl: string, - opts: Partial = {}, + { + forceBaseUrl, + ...opts + }: Partial = { + credentials: "include", + }, ) { + const basePath = + forceBaseUrl ?? + guessPotentiallyProxiedOrySdkUrl({ + knownProxiedUrl: window.location.origin, + }) + const config = new Configuration({ ...opts, - basePath: sdkUrl, + basePath: basePath?.replace(/\/$/, ""), + credentials: opts.credentials ?? "include", headers: { Accept: "application/json", ...opts.headers, diff --git a/packages/elements-react/src/client/index.ts b/packages/elements-react/src/client/index.ts index 90d884e7b..c8a24e81d 100644 --- a/packages/elements-react/src/client/index.ts +++ b/packages/elements-react/src/client/index.ts @@ -1,4 +1,10 @@ +"use client" // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 +export { + SessionProvider, + type SessionContextData, + type SessionProviderProps, +} from "./session-provider" export { useSession } from "./useSession" diff --git a/packages/elements-react/src/client/session-provider.tsx b/packages/elements-react/src/client/session-provider.tsx new file mode 100644 index 000000000..96d4b3f49 --- /dev/null +++ b/packages/elements-react/src/client/session-provider.tsx @@ -0,0 +1,119 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +"use client" +import { Session } from "@ory/client-fetch" +import { createContext, useCallback, useEffect, useState } from "react" +import { frontendClient } from "./frontendClient" + +type SessionState = + | { + session: Session + state: "authenticated" + } + | { + state: "unauthenticated" + } + | { + state: "error" + error: Error + } + +export type SessionContextData = { + /** + * Whether the session is currently being loaded + */ + isLoading: boolean + /** + * Whether the session is being loaded for the first time + * Never true, if a session was passed to the provider + */ + isInitialLoading: boolean + /** + * The current session or null if the user is not authenticated or an error occurred, + * when fetching the session + */ + session: Session | null + /** + * The error that occurred when fetching the session if any + */ + error?: Error + /** + * Refetches the session + */ + refetch: () => Promise +} + +export const SessionContext = createContext({ + session: null, + isLoading: false, + isInitialLoading: true, + refetch: async () => {}, +}) + +export type SessionProviderProps = { + session?: Session | null + baseUrl?: string +} & React.PropsWithChildren + +export function SessionProvider({ + session: initialSession, + children, + baseUrl, +}: SessionProviderProps) { + const [isInitialLoading, setInitialLoading] = useState( + initialSession ? false : true, + ) + const [isLoading, setLoading] = useState(false) + const [sessionState, setSessionState] = useState( + () => { + if (initialSession) { + return { + session: initialSession, + state: initialSession.active ? "authenticated" : "unauthenticated", + } + } + + return undefined + }, + ) + + const fetchSession = useCallback(async () => { + try { + setLoading(true) + const session = await frontendClient({ + forceBaseUrl: baseUrl, + }).toSession() + + setSessionState({ + session, + state: session.active ? "authenticated" : "unauthenticated", + }) + } catch (error) { + setSessionState({ state: "error", error: error as Error }) + } finally { + setLoading(false) + } + }, [baseUrl]) + + useEffect(() => { + if (isInitialLoading) { + void fetchSession().finally(() => setInitialLoading(false)) + } + }, [isInitialLoading, fetchSession]) + + return ( + + {children} + + ) +} diff --git a/packages/elements-react/src/client/useSession.spec.tsx b/packages/elements-react/src/client/useSession.spec.tsx index 9415db38c..8b22c3106 100644 --- a/packages/elements-react/src/client/useSession.spec.tsx +++ b/packages/elements-react/src/client/useSession.spec.tsx @@ -7,8 +7,10 @@ import { Session } from "@ory/client-fetch" import "@testing-library/jest-dom" import "@testing-library/jest-dom/jest-globals" import { act, render, screen, waitFor } from "@testing-library/react" -import { sessionStore, useSession } from "./useSession" +import { useSession } from "./useSession" import { frontendClient } from "./frontendClient" +import { SessionProvider } from "./session-provider" +import { PropsWithChildren } from "react" jest.mock("./frontendClient", () => ({ frontendClient: jest.fn(() => ({ @@ -18,15 +20,19 @@ jest.mock("./frontendClient", () => ({ // Create a test component to use the hook const TestComponent = () => { - const { session, isLoading, error } = useSession() + const { isLoading, session, error } = useSession() if (isLoading) return
Loading...
- if (error) return
Error: {error}
+ if (error) return
Error: {error.message}
if (session) return
Session: {session.id}
return
No session
} +const TestSessionProvider = ({ children }: { children: React.ReactNode }) => { + return {children} +} + describe("useSession", () => { const mockSession: Session = { id: "test-session-id", @@ -36,16 +42,12 @@ describe("useSession", () => { schema_id: "", schema_url: "", }, + active: true, expires_at: new Date(), } beforeEach(() => { jest.clearAllMocks() - sessionStore.setState({ - isLoading: false, - session: undefined, - error: undefined, - }) }) it("fetches and sets session successfully", async () => { @@ -53,11 +55,16 @@ describe("useSession", () => { toSession: jest.fn().mockResolvedValue(mockSession), }) - render() + const container = render(, { + wrapper: TestSessionProvider, + }) // Initially, it should show loading - expect(screen.getByText("Loading...")).toBeInTheDocument() + await waitFor(() => + expect(screen.getByText("Loading...")).toBeInTheDocument(), + ) + act(() => container.rerender()) // Wait for the hook to update await waitFor(() => expect( @@ -74,11 +81,14 @@ describe("useSession", () => { toSession: jest.fn().mockResolvedValue(mockSession), }) - render() + render(, { + wrapper: TestSessionProvider, + }) // Initially, it should show loading expect(screen.getByText("Loading...")).toBeInTheDocument() + // act(() => container.rerender()) // Wait for the hook to update await waitFor(() => expect( @@ -91,7 +101,7 @@ describe("useSession", () => { // this is fine, because jest is not calling the function // eslint-disable-next-line @typescript-eslint/unbound-method - expect(frontendClient("").toSession).toHaveBeenCalledTimes(1) + expect(frontendClient().toSession).toHaveBeenCalledTimes(1) act(() => { render() @@ -99,7 +109,7 @@ describe("useSession", () => { // this is fine, because jest is not calling the function // eslint-disable-next-line @typescript-eslint/unbound-method - expect(frontendClient("").toSession).toHaveBeenCalledTimes(1) + expect(frontendClient().toSession).toHaveBeenCalledTimes(1) }) it("handles errors during session fetching", async () => { @@ -108,7 +118,7 @@ describe("useSession", () => { toSession: jest.fn().mockRejectedValue(new Error(errorMessage)), }) - render() + render(, { wrapper: TestSessionProvider }) // Initially, it should show loading expect(screen.getByText("Loading...")).toBeInTheDocument() @@ -122,24 +132,46 @@ describe("useSession", () => { expect(screen.getByText(`Error: ${errorMessage}`)).toBeInTheDocument() }) - it("does not fetch session if already loading or session is set", async () => { + it("does not fetch session if session is provided to provider", () => { ;(frontendClient as jest.Mock).mockReturnValue({ toSession: jest.fn(), }) // First render: no session, simulate loading - render() + render(, { + wrapper: ({ children }: PropsWithChildren) => ( + + {children} + + ), + }) // Initially, it should show loading - expect(screen.getByText("Loading...")).toBeInTheDocument() + expect(screen.getByText("Session: provided-session")).toBeInTheDocument() + + // this is fine, because jest is not calling the function + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(frontendClient().toSession).toHaveBeenCalledTimes(0) + }) + + it("fetches session automatically if not provided", async () => { + ;(frontendClient as jest.Mock).mockReturnValue({ + toSession: jest.fn().mockResolvedValue(mockSession), + }) + + // First render: no session, simulate loading + render(, { + wrapper: ({ children }: PropsWithChildren) => ( + {children} + ), + }) - // Simulate session already being set in the store await waitFor(() => - expect(screen.getByText("No session")).toBeInTheDocument(), + expect(screen.getByText("Session: test-session-id")).toBeInTheDocument(), ) // this is fine, because jest is not calling the function // eslint-disable-next-line @typescript-eslint/unbound-method - expect(frontendClient("").toSession).toHaveBeenCalledTimes(1) + expect(frontendClient().toSession).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/elements-react/src/client/useSession.ts b/packages/elements-react/src/client/useSession.ts index a3a9425c4..9b177687a 100644 --- a/packages/elements-react/src/client/useSession.ts +++ b/packages/elements-react/src/client/useSession.ts @@ -2,80 +2,37 @@ // SPDX-License-Identifier: Apache-2.0 "use client" -import { Session } from "@ory/client-fetch" -import { useCallback, useEffect } from "react" -import { create, useStore } from "zustand" -import { subscribeWithSelector } from "zustand/middleware" -import { frontendClient } from "./frontendClient" - -type SessionStore = { - setIsLoading: (loading: boolean) => void - setSession: (session: Session) => void - isLoading: boolean - session: Session | undefined - error: string | undefined - setError: (error: string | undefined) => void -} - -export const sessionStore = create()( - subscribeWithSelector((set) => ({ - isLoading: false, - setIsLoading: (isLoading: boolean) => set({ isLoading }), - session: undefined, - setSession: (session: Session) => set({ session }), - error: undefined, - setError: (error: string | undefined) => set({ error }), - })), -) +import { useContext } from "react" +import { SessionContext } from "./session-provider" /** * A hook to get the current session from the Ory Network. * * Usage: * ```ts - * const { session, error, isLoading } = useSession() + * const session = useSession() + * + * if (session.state == "loading") { + * return
Loading...
+ * } + * + * if (session.state == "authenticated") { + * return
Session: {session.session.id}
+ * } * ``` * - * @returns The current session, error and loading state. + * :::note + * This is a client-side hook and must be used within a React component. + * On the server, you can use the getServerSession function from `@ory/nextjs` + * and hydrate SessionProvider with the session. + * ::: + * + * @returns The current session, and error or loading state. */ -export const useSession = (config?: { sdk: { url: string } }) => { - const store = useStore(sessionStore) - - const fetchSession = useCallback(async () => { - const { session, isLoading, setSession, setIsLoading, setError } = - sessionStore.getState() - - if (!!session || isLoading) { - return - } - - setIsLoading(true) - - try { - const sessionData = await frontendClient( - config?.sdk.url ?? - window.location.protocol + "//" + window.location.host, - ).toSession() - setSession(sessionData) - } catch (e) { - setError(e instanceof Error ? e.message : "Unknown error occurred") - if (!config?.sdk.url) { - console.error( - "Could not fetch session. Make sure you have set the SDK URL in the config.", - ) - } - } finally { - setIsLoading(false) - } - }, [config?.sdk.url]) - - useEffect(() => { - void fetchSession() - }, [fetchSession]) - return { - session: store.session, - error: store.error, - isLoading: store.isLoading, +export function useSession() { + if (!SessionContext) { + throw new Error("[Ory/Elements] useSession must be used on the client") } + return useContext(SessionContext) } diff --git a/packages/elements-react/src/theme/default/components/ui/user-menu.tsx b/packages/elements-react/src/theme/default/components/ui/user-menu.tsx index 7bb802267..6ae2f7330 100644 --- a/packages/elements-react/src/theme/default/components/ui/user-menu.tsx +++ b/packages/elements-react/src/theme/default/components/ui/user-menu.tsx @@ -18,7 +18,7 @@ import { import { UserAvatar } from "./user-avater" type UserMenuProps = { - session?: Session + session: Session | null logoutFlow?: LogoutFlow } diff --git a/packages/elements-react/src/theme/default/utils/__tests__/user.spec.ts b/packages/elements-react/src/theme/default/utils/__tests__/user.spec.ts index 32fce81a8..8be63ef68 100644 --- a/packages/elements-react/src/theme/default/utils/__tests__/user.spec.ts +++ b/packages/elements-react/src/theme/default/utils/__tests__/user.spec.ts @@ -13,7 +13,7 @@ const identityBase: Identity = { describe("getUserInitials", () => { test("should return empty strings for primary, secondary, and avatar when no session is provided", () => { - const result: UserInitials = getUserInitials() + const result: UserInitials = getUserInitials(null) expect(result).toEqual({ primary: "", secondary: "", diff --git a/packages/elements-react/src/theme/default/utils/user.ts b/packages/elements-react/src/theme/default/utils/user.ts index ccae67f70..4aec50a7a 100644 --- a/packages/elements-react/src/theme/default/utils/user.ts +++ b/packages/elements-react/src/theme/default/utils/user.ts @@ -15,7 +15,7 @@ function isTraitsIndexable( return typeof traits === "object" && traits !== null } -export const getUserInitials = (session?: Session): UserInitials => { +export const getUserInitials = (session: Session | null): UserInitials => { const avatar = "" let primary = "" let secondary = "" diff --git a/packages/elements-react/tsup.config.ts b/packages/elements-react/tsup.config.ts index 96cb6df3e..6026b1548 100644 --- a/packages/elements-react/tsup.config.ts +++ b/packages/elements-react/tsup.config.ts @@ -33,7 +33,7 @@ export default defineConfig([ sourcemap: true, bundle: false, format: ["cjs", "esm"], - entry: ["src/client/**/*.ts"], + entry: ["src/client/**/*.{ts,tsx}", "!src/**/*.spec.{tsx,ts}"], outDir: "dist/client", }, { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index d65d9b4cb..427bbf663 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -9,12 +9,11 @@ "dependencies": { "@ory/client-fetch": "^1.15.6", "cookie": "^1.0.1", - "psl": "^1.10.0", + "psl": "^1.15.0", "set-cookie-parser": "^2.7.1" }, "devDependencies": { "@types/cookie": "^0.6.0", - "@types/psl": "^1.1.3", "@types/set-cookie-parser": "^2.4.10", "babel-jest": "^29.7.0", "jest-esm-transformer": "^1.0.0", diff --git a/packages/nextjs/src/app/index.ts b/packages/nextjs/src/app/index.ts index 18e19f5bd..72de6a2de 100644 --- a/packages/nextjs/src/app/index.ts +++ b/packages/nextjs/src/app/index.ts @@ -6,5 +6,7 @@ export { getLoginFlow } from "./login" export { getRegistrationFlow } from "./registration" export { getRecoveryFlow } from "./recovery" export { getVerificationFlow } from "./verification" +export { getSettingsFlow } from "./settings" +export { getServerSession } from "./session" export type { OryPageParams } from "./utils" diff --git a/packages/nextjs/src/app/session.ts b/packages/nextjs/src/app/session.ts new file mode 100644 index 000000000..c92f97bfe --- /dev/null +++ b/packages/nextjs/src/app/session.ts @@ -0,0 +1,15 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { Session } from "@ory/client-fetch" +import { serverSideFrontendClient } from "./client" +import { getCookieHeader } from "./utils" + +export async function getServerSession(): Promise { + const cookie = await getCookieHeader() + return serverSideFrontendClient + .toSession({ + cookie, + }) + .catch(() => null) +} diff --git a/packages/nextjs/src/app/settings.ts b/packages/nextjs/src/app/settings.ts new file mode 100644 index 000000000..912a870ef --- /dev/null +++ b/packages/nextjs/src/app/settings.ts @@ -0,0 +1,53 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 +import { FlowType, SettingsFlow } from "@ory/client-fetch" + +import { initOverrides, QueryParams } from "../types" +import { serverSideFrontendClient } from "./client" +import { getFlow } from "./flow" +import { toFlowParams } from "./utils" + +/** + * Use this method in an app router page to fetch an existing login flow or to create a new one. This method works with server-side rendering. + * + * ``` + * import { Login } from "@ory/elements-react/theme" + * import { getLoginFlow, OryPageParams } from "@ory/nextjs/app" + * import { enhanceConfig } from "@ory/nextjs" + * + * import config from "@/ory.config" + * import CardHeader from "@/app/auth/login/card-header" + * + * export default async function LoginPage(props: OryPageParams) { + * const flow = await getLoginFlow(props.searchParams) + * + * if (!flow) { + * return null + * } + * + * return ( + * + * ) + * } + * ``` + * + * @param params - The query parameters of the request. + */ +export async function getSettingsFlow( + params: QueryParams | Promise, +): Promise { + const p = await toFlowParams(await params) + return getFlow( + await params, + () => serverSideFrontendClient.getSettingsFlowRaw(p, initOverrides), + FlowType.Settings, + ) +} diff --git a/packages/nextjs/src/app/utils.ts b/packages/nextjs/src/app/utils.ts index 966a65a51..6c0168b27 100644 --- a/packages/nextjs/src/app/utils.ts +++ b/packages/nextjs/src/app/utils.ts @@ -30,5 +30,5 @@ export async function getPublicUrl() { } export interface OryPageParams { - searchParams: Promise + searchParams: Promise<{ [key: string]: string | string[] | undefined }> } diff --git a/packages/nextjs/src/types.ts b/packages/nextjs/src/types.ts index b9129eb5c..19bc32939 100644 --- a/packages/nextjs/src/types.ts +++ b/packages/nextjs/src/types.ts @@ -72,10 +72,15 @@ export interface OryConfig { * `/my-settings`. */ settingsUiPath?: string + + /** + * Set this to use a custom default redirect URI. This path should be relative to your application's base URL. + */ + defaultRedirectUri?: string } } -export type QueryParams = { [key: string]: string } +export type QueryParams = { [key: string]: string | string[] | undefined } export const initOverrides: RequestInit = { cache: "no-cache", diff --git a/packages/nextjs/src/utils/cookie.ts b/packages/nextjs/src/utils/cookie.ts index cd2e1013d..8d02f2b2b 100644 --- a/packages/nextjs/src/utils/cookie.ts +++ b/packages/nextjs/src/utils/cookie.ts @@ -1,8 +1,8 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { OryConfig } from "../types" import { errorCodes, ErrorResult, parse } from "psl" +import { OryConfig } from "../types" function isErrorResult( result: unknown, diff --git a/packages/nextjs/src/utils/rewrite.ts b/packages/nextjs/src/utils/rewrite.ts index 45cc82d38..50f14d9f1 100644 --- a/packages/nextjs/src/utils/rewrite.ts +++ b/packages/nextjs/src/utils/rewrite.ts @@ -18,6 +18,7 @@ export function rewriteUrls( ["/ui/login", config.override?.loginUiPath], ["/ui/verification", config.override?.verificationUiPath], ["/ui/settings", config.override?.settingsUiPath], + ["/ui/welcome", config.override?.defaultRedirectUri], ].entries()) { const match = joinUrlPaths(matchBaseUrl, matchPath || "") if (replaceWith && source.startsWith(match)) { diff --git a/packages/nextjs/tsconfig.lib.json b/packages/nextjs/tsconfig.lib.json index 32a7f2e06..4e901cbf0 100644 --- a/packages/nextjs/tsconfig.lib.json +++ b/packages/nextjs/tsconfig.lib.json @@ -4,8 +4,9 @@ "outDir": "../../dist/out-tsc", "declaration": true, "types": ["node"], - "rootDir": "./src" + "rootDir": "./src", + "jsx": "react-jsx" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] }