@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.
- package/package.json +4 -3
- package/src/api-manager/datasources/d365/d365-order.datasource.ts +1 -1
- package/src/api-manager/datasources/d365/d365-organization.datasource.ts +1 -1
- package/src/api-manager/datasources/d365/d365-product.datasource.ts +2 -2
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +1 -1
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +7 -5
- package/src/api-manager/datasources/e4/middleware.ts +35 -0
- package/src/api-manager/lib/get-graphql-client.ts +7 -6
- package/src/api-manager/services/create-query.ts +1 -46
- package/src/auth/better-auth.ts +89 -0
- package/src/auth/signout.ts +5 -34
- package/src/components/header/__tests__/header.test.tsx +9 -2
- package/src/components/header/__tests__/user.test.tsx +34 -19
- package/src/components/header/components/user.tsx +34 -18
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/add-to-wishlist-dialog.tsx +3 -1
- package/src/components/wishlist-dialogs/add-to-wishlist/compound/wishlist-dialog-header.tsx +3 -1
- package/src/hooks/use-nextauth-session.ts +10 -14
- package/src/pages/cart/__tests__/checkout-form.test.tsx +4 -2
- package/src/api-manager/datasources/d365/utils/decode-jwt.ts +0 -10
- package/src/auth/auth-options.test.ts +0 -70
- package/src/auth/auth-options.ts +0 -101
- package/src/auth/next-auth-cookie-manager.ts +0 -87
- package/src/auth/providers/aadb2c-provider.ts +0 -32
- package/src/auth/providers/authentik-provider.ts +0 -24
- package/src/auth/providers/entra-external-id-provider.ts +0 -50
- package/src/auth/providers/index.ts +0 -16
- package/src/auth/providers/keycloak-provider.ts +0 -22
- package/src/auth/refresh-token.ts +0 -132
- 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
|
|
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": "
|
|
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 {
|
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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: !!
|
|
31
|
-
token:
|
|
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 {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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;
|
package/src/auth/signout.ts
CHANGED
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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("
|
|
11
|
-
|
|
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.
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
+
it("calls signOut when session is null after pending", () => {
|
|
98
110
|
mockUseSession.mockReturnValue({
|
|
99
|
-
data:
|
|
100
|
-
|
|
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: {
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
75
|
+
const { data: session, isPending } = useBetterAuthSession();
|
|
76
|
+
const status = isPending
|
|
77
|
+
? "loading"
|
|
78
|
+
: session
|
|
79
|
+
? "authenticated"
|
|
80
|
+
: "unauthenticated";
|
|
70
81
|
|
|
71
|
-
if (session
|
|
72
|
-
//
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 {
|
|
258
|
+
const { handleSignIn } = ctx;
|
|
243
259
|
return (
|
|
244
260
|
<Button
|
|
245
261
|
size="small"
|
|
246
262
|
variant="contained"
|
|
247
263
|
color="primary"
|
|
248
|
-
onClick={() =>
|
|
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 {
|
|
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 {
|
|
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
|
|