@evenicanpm/storefront-core 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +4 -3
  2. package/src/api-manager/datasources/d365/d365-order.datasource.ts +1 -1
  3. package/src/api-manager/datasources/d365/d365-organization.datasource.ts +1 -1
  4. package/src/api-manager/datasources/d365/d365-product.datasource.ts +2 -2
  5. package/src/api-manager/datasources/d365/d365-user.datasource.ts +1 -1
  6. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +7 -5
  7. package/src/api-manager/datasources/e4/middleware.ts +35 -0
  8. package/src/api-manager/lib/get-graphql-client.ts +7 -6
  9. package/src/api-manager/services/create-query.ts +1 -46
  10. package/src/auth/better-auth.ts +89 -0
  11. package/src/auth/signout.ts +5 -34
  12. package/src/components/header/__tests__/header.test.tsx +9 -2
  13. package/src/components/header/__tests__/user.test.tsx +34 -19
  14. package/src/components/header/components/user.tsx +34 -18
  15. package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +3 -1
  16. package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header.tsx +3 -1
  17. package/src/hooks/use-nextauth-session.ts +10 -14
  18. package/src/pages/cart/__tests__/checkout-form.test.tsx +4 -2
  19. package/src/api-manager/datasources/d365/utils/decode-jwt.ts +0 -10
  20. package/src/auth/auth-options.test.ts +0 -70
  21. package/src/auth/auth-options.ts +0 -101
  22. package/src/auth/next-auth-cookie-manager.ts +0 -87
  23. package/src/auth/providers/aadb2c-provider.ts +0 -32
  24. package/src/auth/providers/authentik-provider.ts +0 -24
  25. package/src/auth/providers/entra-external-id-provider.ts +0 -50
  26. package/src/auth/providers/index.ts +0 -16
  27. package/src/auth/providers/keycloak-provider.ts +0 -22
  28. package/src/auth/refresh-token.ts +0 -132
  29. package/src/auth/types/next-auth.d.ts +0 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evenicanpm/storefront-core",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Core module for D365/e4 Headless Storefront",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -32,13 +32,14 @@
32
32
  "peerDependencies": {
33
33
  "@msdyn365-commerce/retail-proxy": "9.50",
34
34
  "@tanstack/react-query": "^5.76.1",
35
+ "better-auth": "*",
35
36
  "graphql": "^16.11.0",
36
37
  "graphql-request": "^7.1.2",
37
38
  "jsonwebtoken": "^9.0.2",
38
39
  "lodash": "^4.17.21",
39
40
  "next": "^15.2.1",
40
- "next-auth": "^4.24.11",
41
41
  "object-mapper": "^6.2.0",
42
+ "set-cookie-parser": "^2.7.1",
42
43
  "typedoc": "^0.28.9",
43
44
  "typedoc-plugin-merge-modules": "^7.0.0",
44
45
  "typescript": "^5.8.3",
@@ -49,5 +50,5 @@
49
50
  "esbuild": "0.19.11"
50
51
  }
51
52
  },
52
- "gitHead": "bbaff05f6f592114ad7a25caeee531e4fa1b14cf"
53
+ "gitHead": "21ad4044713e4d6625e30c745dd6cd5c7c52b0b3"
53
54
  }
@@ -11,8 +11,8 @@ import {
11
11
  type ICallerContext,
12
12
  SearchLocation,
13
13
  } from "@msdyn365-commerce/retail-proxy";
14
- import type { IContext } from "@msdyn365-commerce/retail-proxy/dist/Interfaces/ICallerContext";
15
14
  import { getSalesOrderDetailsByTransactionIdAsync } from "@msdyn365-commerce/retail-proxy/dist/DataActions/SalesOrdersDataActions.g";
15
+ import type { IContext } from "@msdyn365-commerce/retail-proxy/dist/Interfaces/ICallerContext";
16
16
  import uniq from "lodash/uniq";
17
17
 
18
18
  class D365OrderDatasource extends D365DatasourceBase implements OrderApi {
@@ -23,7 +23,7 @@ class D365OrganizationDatasource
23
23
  implements OrganizationApi
24
24
  {
25
25
  private getRetailContext(context: ICallerContext): IContext {
26
- return { callerContext: context };
26
+ return context as unknown as IContext;
27
27
  }
28
28
 
29
29
  async getStoresList(
@@ -5,6 +5,7 @@ import {
5
5
  testMedia,
6
6
  validateAndSetMediaUrls,
7
7
  } from "@evenicanpm/storefront-core/src/api-manager/datasources/d365/utils/product-images-utils";
8
+ import type { CategoriesQuery } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
8
9
  import type {
9
10
  GetProductByIdInput,
10
11
  GetProductByIdsInput,
@@ -15,7 +16,6 @@ import type {
15
16
  SearchByCriteriaResponse,
16
17
  searchProductsInput,
17
18
  } from "@evenicanpm/storefront-core/src/api-manager/schemas/product.schema";
18
- import type { CategoriesQuery } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
19
19
  import type { ProductApi } from "@evenicanpm/storefront-core/src/api-manager/types/Datasource";
20
20
  import {
21
21
  executeAction,
@@ -58,7 +58,7 @@ const setFullProductImgUrl = async (
58
58
 
59
59
  class D365ProductDatasource extends D365DatasourceBase implements ProductApi {
60
60
  private getRetailContext(context: ICallerContext): IContext {
61
- return { callerContext: context };
61
+ return context as unknown as IContext;
62
62
  }
63
63
 
64
64
  /**
@@ -53,7 +53,7 @@ function decodeIdTokenClaims(token: string): IdTokenClaims {
53
53
 
54
54
  class D365UserDatasource extends D365DatasourceBase implements UserApi {
55
55
  private getRetailContext(context: ICallerContext): IContext {
56
- return { callerContext: context };
56
+ return context as unknown as IContext;
57
57
  }
58
58
 
59
59
  async updateUser(user: User): Promise<User> {
@@ -1,8 +1,8 @@
1
1
  "use server";
2
2
  import type { ICallerContext } from "@msdyn365-commerce/retail-proxy";
3
- import { cookies } from "next/headers";
3
+ import { cookies, headers as getHeaders } from "next/headers";
4
4
  import { getLocale } from "next-intl/server";
5
- import { manuallyRetrieveSessionCookie } from "@/auth/next-auth-cookie-manager";
5
+ import { auth } from "@/auth/better-auth";
6
6
 
7
7
  /**
8
8
  * Retrieves the D365 context from the "D365Context" cookie, enriches it with the current session token,
@@ -21,14 +21,16 @@ import { manuallyRetrieveSessionCookie } from "@/auth/next-auth-cookie-manager";
21
21
  export async function getContextFromCookie(): Promise<ICallerContext> {
22
22
  const cookieStore = await cookies();
23
23
  const d365Context = cookieStore.get("D365Context");
24
- const token = await manuallyRetrieveSessionCookie();
24
+ const session = await auth.api.getSession({ headers: await getHeaders() });
25
+ const accessToken =
26
+ (session?.session as { accessToken?: string })?.accessToken || "";
25
27
  const locale = await getLocale();
26
28
  if (d365Context) {
27
29
  const context = JSON.parse(d365Context.value) as ICallerContext;
28
30
  context.requestContext.operationId = crypto.randomUUID();
29
31
  context.requestContext.user = {
30
- isAuthenticated: !!token?.accessToken,
31
- token: token?.accessToken ?? "",
32
+ isAuthenticated: !!accessToken,
33
+ token: accessToken,
32
34
  };
33
35
  context.requestContext.locale = locale;
34
36
  return context;
@@ -0,0 +1,35 @@
1
+ import createGraphQLClient from "@evenicanpm/storefront-core/src/lib/create-graphql-client";
2
+ import type { NextRequest, NextResponse } from "next/server";
3
+ import { parse, splitCookiesString } from "set-cookie-parser";
4
+
5
+ const setE4SessionCookies = async (
6
+ response: NextResponse,
7
+ request: NextRequest,
8
+ ) => {
9
+ const cookiesAlreadySet = request.cookies.get("storefront");
10
+ if (cookiesAlreadySet) return response;
11
+
12
+ const e4Api = await createGraphQLClient();
13
+
14
+ const session = await e4Api.e4ReadSessionCustomer();
15
+
16
+ const cookieString = session?.headers?.get("set-cookie");
17
+ if (!cookieString) return response;
18
+ const splitCookies = parse(
19
+ splitCookiesString(decodeURIComponent(cookieString)),
20
+ );
21
+
22
+ splitCookies.forEach((cookie) => {
23
+ response.cookies.set({
24
+ name: cookie.name,
25
+ value: cookie.value,
26
+ httpOnly: true,
27
+ expires: cookie.expires,
28
+ secure: true,
29
+ });
30
+ });
31
+
32
+ return response;
33
+ };
34
+
35
+ export default setE4SessionCookies;
@@ -2,9 +2,8 @@
2
2
 
3
3
  import { getSdk } from "@evenicanpm/storefront-core/src/api-manager/datasources/e4/graphqlRequestSdk";
4
4
  import { GraphQLClient } from "graphql-request";
5
- import { cookies } from "next/headers";
6
- import { getServerSession } from "next-auth/next";
7
- import authOptions from "@/auth/auth-options";
5
+ import { cookies, headers as getHeaders } from "next/headers";
6
+ import { auth } from "@/auth/better-auth";
8
7
 
9
8
  /**
10
9
  * Initializes and returns a cached GraphQL client
@@ -28,9 +27,11 @@ const getGraphQLClient = async (withAuth = false) => {
28
27
  }
29
28
 
30
29
  if (withAuth) {
31
- const session = await getServerSession(authOptions());
32
- if (session?.accessToken) {
33
- headers.Authorization = `Bearer ${session.accessToken}`;
30
+ const session = await auth.api.getSession({ headers: await getHeaders() });
31
+ const accessToken = (session?.session as { accessToken?: string })
32
+ ?.accessToken;
33
+ if (accessToken) {
34
+ headers.Authorization = `Bearer ${accessToken}`;
34
35
  }
35
36
  }
36
37
 
@@ -7,14 +7,9 @@ import {
7
7
  useQuery,
8
8
  useSuspenseQuery,
9
9
  } from "@tanstack/react-query";
10
- import { Mutex } from "async-mutex";
11
- import jwt from "jsonwebtoken";
12
- import { getSession } from "next-auth/react";
13
- import { manuallyRetrieveSessionCookie } from "@/auth/next-auth-cookie-manager";
14
10
  import signOut from "@/auth/signout";
15
11
 
16
12
  const QUERY_ROUTE = "/api";
17
- const mutex = new Mutex();
18
13
 
19
14
  export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
20
15
  T,
@@ -27,22 +22,6 @@ export type Query<TInput, TOutput> = ReturnType<
27
22
 
28
23
  // --- Shared helpers (no closure dependencies) ---
29
24
 
30
- const checkTokenExpiry = async (): Promise<boolean> => {
31
- let token: string | undefined;
32
- if (isServer) {
33
- const session = (await manuallyRetrieveSessionCookie()) ?? undefined;
34
- token = session?.accessToken;
35
- } else {
36
- const session = await getSession();
37
- token = session?.accessToken;
38
- }
39
- const decoded = token && (jwt.decode(token) as { exp?: number } | null);
40
- const currentTime = Math.floor(Date.now() / 1000);
41
- return (
42
- !!decoded && typeof decoded.exp === "number" && decoded.exp < currentTime
43
- );
44
- };
45
-
46
25
  const doFetch = async <TInput>(
47
26
  input: TInput | undefined,
48
27
  apiManagerPath: ApiPath,
@@ -60,27 +39,6 @@ const doFetch = async <TInput>(
60
39
  return result.json();
61
40
  };
62
41
 
63
- // Acquires a mutex before fetching so concurrent requests don't each trigger
64
- // a token refresh simultaneously (double-check pattern after acquiring lock).
65
- const withTokenRefresh = async <TInput>(
66
- input: TInput | undefined,
67
- apiManagerPath: ApiPath,
68
- ) => {
69
- const release = await mutex.acquire();
70
- try {
71
- const headers = new Headers();
72
- if (await checkTokenExpiry()) {
73
- headers.append("should-refresh-token", "true");
74
- }
75
- return await doFetch(input, apiManagerPath, headers);
76
- } catch (error) {
77
- console.error("Error fetching data:", error);
78
- throw new Error("Failed to fetch data");
79
- } finally {
80
- release();
81
- }
82
- };
83
-
84
42
  // --- Factory ---
85
43
 
86
44
  export const createQuery = <TInput, TOutput>(
@@ -103,10 +61,7 @@ export const createQuery = <TInput, TOutput>(
103
61
  return getApiFromPath(apiManager, apiManagerPath)(input);
104
62
  }
105
63
  : async () => {
106
- const isExpired = await checkTokenExpiry();
107
- return isExpired
108
- ? withTokenRefresh(input, apiManagerPath)
109
- : doFetch(input, apiManagerPath);
64
+ return await doFetch(input, apiManagerPath);
110
65
  },
111
66
  enabled: config?.disableProperties
112
67
  ? config.disableProperties.every((prop) => !!input?.[prop])
@@ -0,0 +1,89 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { genericOAuth, customSession } from "better-auth/plugins";
3
+ import { nextCookies } from "better-auth/next-js";
4
+
5
+ const tenantName = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
6
+ const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
7
+
8
+ export const auth = betterAuth({
9
+ secret: process.env.BETTER_AUTH_SECRET,
10
+ baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL,
11
+ session: {
12
+ cookieCache: {
13
+ enabled: true,
14
+ maxAge: 60 * 60, // 1 hour
15
+ strategy: "jwe",
16
+ refreshCache: true,
17
+ },
18
+ },
19
+ account: {
20
+ storeStateStrategy: "cookie",
21
+ storeAccountCookie: true,
22
+ },
23
+ plugins: [
24
+ genericOAuth({
25
+ config: [
26
+ {
27
+ providerId: "entra-external-id",
28
+ clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "",
29
+ clientSecret:
30
+ process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "",
31
+ discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
32
+ scopes: (
33
+ process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES ||
34
+ "offline_access openid email profile"
35
+ ).split(" "),
36
+ pkce: true,
37
+ prompt: "login",
38
+ getUserInfo: async (tokens) => {
39
+ // Decode the id_token to get user claims
40
+ const idToken = tokens.idToken;
41
+ if (idToken) {
42
+ const parts = idToken.split(".");
43
+ if (parts.length === 3) {
44
+ const payload = JSON.parse(
45
+ Buffer.from(parts[1], "base64url").toString(),
46
+ );
47
+ return {
48
+ id: payload.oid || payload.sub,
49
+ name: payload.name,
50
+ email:
51
+ payload.email ||
52
+ payload.preferred_username ||
53
+ payload.emails?.[0],
54
+ emailVerified: !!payload.email_verified,
55
+ };
56
+ }
57
+ }
58
+ return null;
59
+ },
60
+ },
61
+ ],
62
+ }),
63
+ customSession(async ({ user, session }, ctx) => {
64
+ let accessToken = "";
65
+ try {
66
+ const accounts = await ctx.context.internalAdapter.findAccounts(
67
+ user.id,
68
+ );
69
+ const account = accounts?.find(
70
+ (a) => a.providerId === "entra-external-id",
71
+ );
72
+ // For Entra External ID, use id_token as the access token
73
+ accessToken = account?.idToken || account?.accessToken || "";
74
+ } catch {
75
+ // Account data may not be accessible in all modes
76
+ }
77
+ return {
78
+ user,
79
+ session: {
80
+ ...session,
81
+ accessToken,
82
+ },
83
+ };
84
+ }),
85
+ nextCookies(), // must be last plugin
86
+ ],
87
+ });
88
+
89
+ export type Session = typeof auth.$Infer.Session;
@@ -1,42 +1,13 @@
1
- const fetchCsrfToken = async (): Promise<string> => {
2
- try {
3
- const response = await fetch("/api/auth/csrf");
4
- if (!response.ok) {
5
- throw new Error(`Failed to fetch CSRF token: ${response.statusText}`);
6
- }
7
- const data: { csrfToken: string } = await response.json();
8
- return data.csrfToken;
9
- } catch (error) {
10
- console.error("Error fetching CSRF token:", error);
11
- throw error;
12
- }
13
- };
1
+ import { createAuthClient } from "better-auth/react";
2
+
3
+ const authClient = createAuthClient();
14
4
 
15
5
  /**
16
- * Alternative to signOut from "next-auth/react" - https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/react.tsx
17
- * Solution to known next-auth issue - https://github.com/nextauthjs/next-auth/issues/4612
6
+ * Signs the user out and redirects to the home page.
18
7
  */
19
8
  const signOut = async (): Promise<void> => {
20
9
  try {
21
- const csrfToken = await fetchCsrfToken();
22
-
23
- const formData = new URLSearchParams({
24
- csrfToken,
25
- json: "true",
26
- });
27
-
28
- const response = await fetch("/api/auth/signout", {
29
- method: "POST",
30
- headers: {
31
- "Content-Type": "application/x-www-form-urlencoded",
32
- },
33
- body: formData,
34
- });
35
-
36
- if (!response.ok) {
37
- throw new Error(`Failed to sign out: ${response.statusText}`);
38
- }
39
-
10
+ await authClient.signOut();
40
11
  globalThis.location.href = "/";
41
12
  } catch (error) {
42
13
  console.error(error);
@@ -7,8 +7,15 @@ vi.mock("@evenicanpm/storefront-core/src/hooks/use-nextauth-session", () => ({
7
7
  useAppSession: vi.fn(() => ({ session: null })),
8
8
  }));
9
9
 
10
- vi.mock("next-auth/react", () => ({
11
- useSession: () => ({ data: null, status: "unauthenticated" }),
10
+ vi.mock("better-auth/react", () => ({
11
+ createAuthClient: () => ({
12
+ useSession: () => ({ data: null, isPending: false }),
13
+ signIn: { oauth2: vi.fn() },
14
+ }),
15
+ }));
16
+
17
+ vi.mock("better-auth/client/plugins", () => ({
18
+ genericOAuthClient: () => ({}),
12
19
  }));
13
20
 
14
21
  vi.mock(
@@ -4,6 +4,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
4
  import { NextIntlClientProvider } from "next-intl";
5
5
  import { vi } from "vitest";
6
6
 
7
+ const { mockMutate, mockHandleRedirectPromise, mockUseSession } = vi.hoisted(
8
+ () => ({
9
+ mockMutate: vi.fn().mockResolvedValue({}),
10
+ mockHandleRedirectPromise: vi.fn().mockResolvedValue(null),
11
+ mockUseSession: vi.fn(),
12
+ }),
13
+ );
14
+
7
15
  vi.mock(
8
16
  "@evenicanpm/storefront-core/src/api-manager/services/session/mutations/session-init",
9
17
  () => ({
@@ -11,10 +19,6 @@ vi.mock(
11
19
  }),
12
20
  );
13
21
 
14
- const mockMutate = vi.fn().mockResolvedValue({});
15
- const mockHandleRedirectPromise = vi.fn().mockResolvedValue(null);
16
- const mockUseSession = vi.fn();
17
-
18
22
  vi.mock(
19
23
  "@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user",
20
24
  () => ({
@@ -36,9 +40,17 @@ vi.mock("next/navigation", () => ({
36
40
  usePathname: () => "/mock-path",
37
41
  }));
38
42
 
39
- vi.doMock("next-auth/react", () => ({
40
- useSession: mockUseSession,
41
- signIn: vi.fn(),
43
+ vi.mock("better-auth/react", () => {
44
+ return {
45
+ createAuthClient: () => ({
46
+ useSession: mockUseSession,
47
+ signIn: { oauth2: vi.fn() },
48
+ }),
49
+ };
50
+ });
51
+
52
+ vi.mock("better-auth/client/plugins", () => ({
53
+ genericOAuthClient: () => ({}),
42
54
  }));
43
55
 
44
56
  vi.mock("@evenicanpm/storefront-core/src/auth/msal", () => ({
@@ -58,7 +70,7 @@ describe.skip("User Component", async () => {
58
70
  it("renders login button when user is not authenticated", () => {
59
71
  mockUseSession.mockReturnValue({
60
72
  data: null,
61
- status: "unauthenticated",
73
+ isPending: false,
62
74
  });
63
75
  render(
64
76
  <NextIntlClientProvider locale="en" messages={messages}>
@@ -75,9 +87,9 @@ describe.skip("User Component", async () => {
75
87
  mockUseSession.mockReturnValue({
76
88
  data: {
77
89
  user: { email: "test@example.com" },
78
- accessToken: "mockAccessToken",
90
+ session: { accessToken: "mockAccessToken" },
79
91
  },
80
- status: "authenticated",
92
+ isPending: false,
81
93
  });
82
94
 
83
95
  render(
@@ -94,10 +106,10 @@ describe.skip("User Component", async () => {
94
106
  expect(userMenuButton).toBeInTheDocument();
95
107
  });
96
108
 
97
- it("calls signOut when session.error is present", () => {
109
+ it("calls signOut when session is null after pending", () => {
98
110
  mockUseSession.mockReturnValue({
99
- data: { error: "Some auth error" },
100
- status: "authenticated",
111
+ data: null,
112
+ isPending: false,
101
113
  });
102
114
 
103
115
  render(
@@ -113,8 +125,11 @@ describe.skip("User Component", async () => {
113
125
  const mockSignOut = vi.mocked(signOut);
114
126
 
115
127
  mockUseSession.mockReturnValue({
116
- data: { user: { email: "test@example.com" }, accessToken: "token" },
117
- status: "authenticated",
128
+ data: {
129
+ user: { email: "test@example.com" },
130
+ session: { accessToken: "token" },
131
+ },
132
+ isPending: false,
118
133
  });
119
134
 
120
135
  render(
@@ -139,9 +154,9 @@ describe.skip("User Component", async () => {
139
154
  mockUseSession.mockReturnValue({
140
155
  data: {
141
156
  user: { email: "test@example.com" },
142
- accessToken: "mockAccessToken",
157
+ session: { accessToken: "mockAccessToken" },
143
158
  },
144
- status: "authenticated",
159
+ isPending: false,
145
160
  });
146
161
 
147
162
  vi.mocked(mockHandleRedirectPromise).mockResolvedValue({
@@ -193,9 +208,9 @@ describe.skip("User Component", async () => {
193
208
  mockUseSession.mockReturnValue({
194
209
  data: {
195
210
  user: { email: "test@example.com" },
196
- accessToken: "mockAccessToken",
211
+ session: { accessToken: "mockAccessToken" },
197
212
  },
198
- status: "authenticated",
213
+ isPending: false,
199
214
  });
200
215
 
201
216
  vi.mocked(mockHandleRedirectPromise).mockRejectedValue(
@@ -23,7 +23,13 @@ import {
23
23
  } from "@mui/material";
24
24
  import { useRouter } from "next/navigation";
25
25
  // Auth components
26
- import { signIn, useSession } from "next-auth/react";
26
+ import { createAuthClient } from "better-auth/react";
27
+ import { genericOAuthClient } from "better-auth/client/plugins";
28
+
29
+ const authClient = createAuthClient({
30
+ plugins: [genericOAuthClient()],
31
+ });
32
+ const { useSession: useBetterAuthSession } = authClient;
27
33
  import type React from "react";
28
34
  import {
29
35
  createContext,
@@ -52,7 +58,7 @@ interface UserContextValue {
52
58
  status: string;
53
59
  menuItems: typeof MENU_ITEMS;
54
60
  handleLogout: () => Promise<void>;
55
- signIn: typeof signIn;
61
+ handleSignIn: () => void;
56
62
  signOut: () => Promise<void>;
57
63
  anchorEl: HTMLButtonElement | null;
58
64
  setAnchorEl: (el: HTMLButtonElement | null) => void;
@@ -66,12 +72,15 @@ interface Props {
66
72
  }
67
73
 
68
74
  const User = ({ children }: Props) => {
69
- const { data: session, status } = useSession();
75
+ const { data: session, isPending } = useBetterAuthSession();
76
+ const status = isPending
77
+ ? "loading"
78
+ : session
79
+ ? "authenticated"
80
+ : "unauthenticated";
70
81
 
71
- if (session?.error) {
72
- // Custom error set in server - /api/auth/[...nextauth]
73
- console.warn("Auth Error", session?.error);
74
- signOutCustom();
82
+ if (session === null && !isPending) {
83
+ // No error handling needed - session is simply null when not authenticated
75
84
  }
76
85
 
77
86
  const isAuthenticated = !!session;
@@ -81,12 +90,12 @@ const User = ({ children }: Props) => {
81
90
 
82
91
  // session init
83
92
  useEffect(() => {
84
- if (isAuthenticated) {
85
- const { accessToken, user } = session;
86
- const email = user?.email;
87
- if (accessToken && email) {
88
- sessionInit({ accessToken, email });
89
- }
93
+ if (isAuthenticated && session?.user?.email) {
94
+ const accessToken =
95
+ (session.session as { accessToken?: string })?.accessToken || "";
96
+ sessionInit({ accessToken, email: session.user.email }).catch(
97
+ console.error,
98
+ );
90
99
  }
91
100
  }, [isAuthenticated, session, sessionInit]);
92
101
 
@@ -124,6 +133,13 @@ const User = ({ children }: Props) => {
124
133
  }
125
134
  };
126
135
 
136
+ const handleSignIn = () => {
137
+ authClient.signIn.oauth2({
138
+ providerId: "entra-external-id",
139
+ callbackURL: "/",
140
+ });
141
+ };
142
+
127
143
  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
128
144
  const open = Boolean(anchorEl);
129
145
 
@@ -132,7 +148,7 @@ const User = ({ children }: Props) => {
132
148
  status,
133
149
  menuItems: MENU_ITEMS,
134
150
  handleLogout,
135
- signIn,
151
+ handleSignIn,
136
152
  signOut: signOutCustom,
137
153
  anchorEl,
138
154
  setAnchorEl,
@@ -149,13 +165,13 @@ User.MobileTrigger = function MobileTrigger() {
149
165
  const ctx = useContext(UserContext);
150
166
  const router = useRouter();
151
167
  if (!ctx) return null;
152
- const { isAuthenticated, signIn } = ctx;
168
+ const { isAuthenticated, handleSignIn } = ctx;
153
169
  return (
154
170
  <IconButton
155
171
  aria-label="User Menu"
156
172
  onClick={() => {
157
173
  if (isAuthenticated) router.push("/account/profile");
158
- else signIn();
174
+ else handleSignIn();
159
175
  }}
160
176
  >
161
177
  <MobileUserIcon style={{ fontSize: "1.25rem" }} />
@@ -239,13 +255,13 @@ User.MenuItem = function MenuItem({
239
255
  User.LoginButton = function LoginButton() {
240
256
  const ctx = useContext(UserContext);
241
257
  if (!ctx) return null;
242
- const { signIn } = ctx;
258
+ const { handleSignIn } = ctx;
243
259
  return (
244
260
  <Button
245
261
  size="small"
246
262
  variant="contained"
247
263
  color="primary"
248
- onClick={() => signIn()}
264
+ onClick={() => handleSignIn()}
249
265
  >
250
266
  <T path="Account.Menu.btnLogin" />
251
267
  </Button>
@@ -8,9 +8,11 @@ import { AddToWishListDialogContext } from "@evenicanpm/storefront-core/src/comp
8
8
  // LOCAL COMPONENTS
9
9
  import DialogHeader from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header";
10
10
  import { Dialog, DialogContent } from "@mui/material";
11
- import { useSession } from "next-auth/react";
11
+ import { createAuthClient } from "better-auth/react";
12
12
  import type React from "react";
13
13
 
14
+ const { useSession } = createAuthClient();
15
+
14
16
  export interface AddToWishlistDialogProps {
15
17
  open: boolean;
16
18
  handleClose: () => void;
@@ -9,8 +9,10 @@ import { H2 } from "@evenicanpm/storefront-core/src/components/Typography";
9
9
  import CreateWishlistButton from "@evenicanpm/storefront-core/src/components/wishlist-dialogs/create-wishlist/create-wishlist-button";
10
10
  import { IconButton, styled, type Theme } from "@mui/material";
11
11
  import useMediaQuery from "@mui/material/useMediaQuery";
12
- import { useSession } from "next-auth/react";
12
+ import { createAuthClient } from "better-auth/react";
13
13
  import { useTranslations } from "next-intl";
14
+
15
+ const { useSession } = createAuthClient();
14
16
  // ICONS
15
17
  import { MdFavorite } from "react-icons/md";
16
18