@evenicanpm/storefront-core 2.4.0 → 2.4.2

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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +90 -77
  3. package/src/api-manager/datasources/d365/d365-address.datasource.ts +142 -66
  4. package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
  5. package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
  6. package/src/api-manager/lib/get-graphql-client.ts +35 -7
  7. package/src/api-manager/services/create-query.ts +36 -3
  8. package/src/api-manager/services/get-query-client.ts +10 -0
  9. package/src/auth/better-auth.ts +282 -15
  10. package/src/cms/blocks/components/footer/data/index.ts +0 -0
  11. package/src/cms/blocks/components/footer/index.tsx +0 -0
  12. package/src/cms/blocks/components/footer/sections/footer-app-store.tsx +0 -0
  13. package/src/cms/blocks/components/footer/sections/footer-contact.tsx +0 -0
  14. package/src/cms/blocks/components/footer/sections/footer-links.tsx +0 -0
  15. package/src/cms/blocks/components/footer/sections/footer-logo.tsx +0 -0
  16. package/src/cms/blocks/components/footer/sections/footer-social-links.tsx +0 -0
  17. package/src/cms/blocks/components/footer/styles/index.ts +0 -0
  18. package/src/cms/blocks/components/hero-carousel/index.tsx +0 -0
  19. package/src/cms/blocks/components/product-carousel/index.tsx +0 -0
  20. package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
  21. package/src/cms/blocks/components/shared/featured-product-card.tsx +0 -0
  22. package/src/cms/blocks/components/shared/product-category-item.tsx +0 -0
  23. package/src/cms/blocks/components/shared/product-grid.tsx +0 -0
  24. package/src/cms/blocks/components/shared/top-categories-card.tsx +0 -0
  25. package/src/cms/blocks/components/shared/top-rating-product-card.tsx +0 -0
  26. package/src/cms/blocks/icons/components/category.tsx +0 -0
  27. package/src/cms/blocks/icons/components/dotted-star.tsx +0 -0
  28. package/src/cms/blocks/icons/components/gift-box.tsx +0 -0
  29. package/src/cms/blocks/icons/components/light.tsx +0 -0
  30. package/src/cms/blocks/icons/components/new-arrival.tsx +0 -0
  31. package/src/cms/blocks/icons/components/rank-badge.tsx +0 -0
  32. package/src/components/categories/category-list/category-list.tsx +9 -5
  33. package/src/components/header/__tests__/user.test.tsx +5 -107
  34. package/src/components/header/components/user.tsx +72 -45
  35. package/src/hooks/use-nextauth-session.ts +0 -33
  36. package/src/lib/auth-client.ts +14 -0
  37. package/src/lib/auth.ts +4 -0
  38. package/src/lib/entra-native-auth.ts +138 -0
  39. package/src/lib/graphqlRequestSdk.ts +0 -0
  40. package/src/lib/native-session.ts +434 -0
  41. package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
  42. package/src/pages/account/profile/profile-form.tsx +285 -0
  43. package/src/pages/account/profile/profile-validation.ts +52 -0
  44. package/src/pages/auth/auth-activate-page.tsx +509 -0
  45. package/src/pages/auth/auth-layout.tsx +73 -0
  46. package/src/pages/auth/auth-login-page.tsx +241 -0
  47. package/src/pages/auth/auth-reset-password-page.tsx +487 -0
  48. package/src/pages/auth/compound/auth-form.tsx +182 -0
  49. package/src/pages/auth/compound/auth-pages.tsx +21 -0
  50. package/src/pages/auth/lib/friendly-error.ts +70 -0
  51. package/src/pages/auth/lib/index.ts +2 -0
  52. package/src/pages/auth/lib/types.ts +5 -0
  53. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
  54. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
  55. package/tsconfig.json +20 -14
  56. package/src/auth/msal.ts +0 -65
  57. package/src/pages/account/profile/profile-button.test.tsx +0 -59
  58. package/src/pages/account/profile/profile-button.tsx +0 -51
@@ -4,6 +4,29 @@ import { cookies, headers as getHeaders } from "next/headers";
4
4
  import { getLocale } from "next-intl/server";
5
5
  import { auth } from "@/auth/better-auth";
6
6
 
7
+ function isJwtStillValid(token?: string): boolean {
8
+ if (!token) return false;
9
+
10
+ try {
11
+ const parts = token.split(".");
12
+ if (parts.length !== 3) return false;
13
+
14
+ const payload = JSON.parse(
15
+ Buffer.from(parts[1] ?? "", "base64url").toString(),
16
+ ) as { exp?: number };
17
+
18
+ if (typeof payload.exp !== "number") {
19
+ return true;
20
+ }
21
+
22
+ const nowInSeconds = Math.floor(Date.now() / 1000);
23
+ // small skew buffer to avoid edge-of-expiration failures
24
+ return payload.exp > nowInSeconds + 30;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
7
30
  /**
8
31
  * Retrieves the D365 context from the "D365Context" cookie, enriches it with the current session token,
9
32
  * locale, and a new operation ID, and returns it as an `ICallerContext` object.
@@ -21,19 +44,30 @@ import { auth } from "@/auth/better-auth";
21
44
  export async function getContextFromCookie(): Promise<ICallerContext> {
22
45
  const cookieStore = await cookies();
23
46
  const d365Context = cookieStore.get("D365Context");
24
- let accessToken = "";
25
- try {
26
- const tokens = await auth.api.getAccessToken({
27
- headers: await getHeaders(),
28
- body: { providerId: "entra-external-id" },
29
- });
30
- accessToken = tokens?.idToken || tokens?.accessToken || "";
31
- } catch {
32
- // User may not be authenticated — continue with empty token
33
- }
47
+
34
48
  const locale = await getLocale();
35
49
  if (d365Context) {
36
50
  const context = JSON.parse(d365Context.value) as ICallerContext;
51
+ const existingToken = context.requestContext?.user?.token || "";
52
+ let sessionToken = "";
53
+
54
+ try {
55
+ const session = await auth.api.getSession({
56
+ headers: await getHeaders(),
57
+ });
58
+ sessionToken =
59
+ (session as { session?: { accessToken?: string } } | null)?.session
60
+ ?.accessToken || "";
61
+ } catch {
62
+ // Fall back to the context cookie token when session lookup is unavailable.
63
+ }
64
+
65
+ let accessToken = "";
66
+ if (isJwtStillValid(sessionToken)) {
67
+ accessToken = sessionToken;
68
+ } else if (isJwtStillValid(existingToken)) {
69
+ accessToken = existingToken;
70
+ }
37
71
  context.requestContext.operationId = crypto.randomUUID();
38
72
  context.requestContext.user = {
39
73
  isAuthenticated: !!accessToken,
@@ -5,6 +5,23 @@ import { GraphQLClient } from "graphql-request";
5
5
  import { cookies, headers as getHeaders } from "next/headers";
6
6
  import { auth } from "@/auth/better-auth";
7
7
 
8
+ const decodeJwtExpMs = (token?: string): number | null => {
9
+ if (!token) return null;
10
+ const parts = token.split(".");
11
+ if (parts.length !== 3 || !parts[1]) return null;
12
+ try {
13
+ const payload = JSON.parse(
14
+ Buffer.from(parts[1], "base64url").toString(),
15
+ ) as {
16
+ exp?: number;
17
+ };
18
+ if (typeof payload.exp !== "number") return null;
19
+ return payload.exp * 1000;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
8
25
  /**
9
26
  * Initializes and returns a cached GraphQL client
10
27
  * @param withAuth (optional) - If true, includes the authorization token
@@ -28,16 +45,27 @@ const getGraphQLClient = async (withAuth = false) => {
28
45
 
29
46
  if (withAuth) {
30
47
  try {
31
- const tokens = await auth.api.getAccessToken({
48
+ const session = await auth.api.getSession({
32
49
  headers: await getHeaders(),
33
- body: { providerId: "entra-external-id" },
34
50
  });
35
- const accessToken = tokens?.idToken || tokens?.accessToken;
36
- if (accessToken) {
37
- headers.Authorization = `Bearer ${accessToken}`;
51
+ const accessToken =
52
+ (session as { session?: { accessToken?: string } } | null)?.session
53
+ ?.accessToken || "";
54
+ if (!accessToken) {
55
+ throw new Error("Authentication required: provider token missing");
38
56
  }
39
- } catch {
40
- // User may not be authenticated — continue without auth header
57
+
58
+ const expMs = decodeJwtExpMs(accessToken);
59
+ if (typeof expMs === "number" && expMs <= Date.now()) {
60
+ throw new Error("Authentication required: provider token expired");
61
+ }
62
+
63
+ headers.Authorization = `Bearer ${accessToken}`;
64
+ } catch (error) {
65
+ // withAuth=true is an explicit contract that a valid token is required.
66
+ throw error instanceof Error
67
+ ? error
68
+ : new Error("Authentication required: unable to resolve access token");
41
69
  }
42
70
  }
43
71
 
@@ -11,6 +11,22 @@ import signOut from "@/auth/signout";
11
11
 
12
12
  const QUERY_ROUTE = "/api";
13
13
 
14
+ class ApiManagerRequestError extends Error {
15
+ status: number;
16
+ path: ApiPath;
17
+ payload?: unknown;
18
+
19
+ constructor(status: number, path: ApiPath, payload?: unknown) {
20
+ super(
21
+ `API request failed for ${JSON.stringify(path)} with status ${status}`,
22
+ );
23
+ this.name = "ApiManagerRequestError";
24
+ this.status = status;
25
+ this.path = path;
26
+ this.payload = payload;
27
+ }
28
+ }
29
+
14
30
  export type ApiPath<T extends keyof DatasourceApis = keyof DatasourceApis> = [
15
31
  T,
16
32
  keyof DatasourceApis[T],
@@ -33,10 +49,24 @@ const doFetch = async <TInput>(
33
49
  body: JSON.stringify({ input, apiManagerPath }),
34
50
  headers,
35
51
  });
52
+
53
+ let payload: unknown;
54
+ try {
55
+ payload = await result.json();
56
+ } catch {
57
+ payload = undefined;
58
+ }
59
+
36
60
  if (result.status === 401) {
37
61
  signOut();
62
+ throw new ApiManagerRequestError(result.status, apiManagerPath, payload);
38
63
  }
39
- return result.json();
64
+
65
+ if (!result.ok) {
66
+ throw new ApiManagerRequestError(result.status, apiManagerPath, payload);
67
+ }
68
+
69
+ return payload;
40
70
  };
41
71
 
42
72
  // --- Factory ---
@@ -58,10 +88,13 @@ export const createQuery = <TInput, TOutput>(
58
88
  queryFn: isServer
59
89
  ? async () => {
60
90
  const apiManager = await createApiManager();
61
- return getApiFromPath(apiManager, apiManagerPath)(input);
91
+ return getApiFromPath<TInput, TOutput>(
92
+ apiManager,
93
+ apiManagerPath,
94
+ )(input);
62
95
  }
63
96
  : async () => {
64
- return await doFetch(input, apiManagerPath);
97
+ return (await doFetch(input, apiManagerPath)) as TOutput;
65
98
  },
66
99
  enabled: config?.disableProperties
67
100
  ? config.disableProperties.every((prop) => !!input?.[prop])
@@ -10,6 +10,16 @@ function makeQueryClient() {
10
10
  defaultOptions: {
11
11
  queries: {
12
12
  staleTime: 60 * 1000,
13
+ refetchOnWindowFocus: false,
14
+ retry: (failureCount, error) => {
15
+ const status =
16
+ typeof error === "object" && error !== null && "status" in error
17
+ ? Number((error as { status?: number }).status)
18
+ : undefined;
19
+
20
+ if (status === 401) return false;
21
+ return failureCount < 1;
22
+ },
13
23
  },
14
24
  dehydrate: {
15
25
  serializeData: (data) => JSON.stringify(data),
@@ -1,19 +1,231 @@
1
+ import type { Auth, BetterAuthOptions } from "better-auth";
1
2
  import { betterAuth } from "better-auth";
2
3
  import { nextCookies } from "better-auth/next-js";
4
+ import { decryptOAuthToken, setTokenUtil } from "better-auth/oauth2";
3
5
  import { customSession, genericOAuth } from "better-auth/plugins";
6
+ import { Pool } from "pg";
7
+
8
+ function toValidDate(dateLike: unknown): Date | null {
9
+ if (dateLike instanceof Date) {
10
+ return Number.isNaN(dateLike.getTime()) ? null : dateLike;
11
+ }
12
+
13
+ if (typeof dateLike === "string" || typeof dateLike === "number") {
14
+ const date = new Date(dateLike);
15
+ return Number.isNaN(date.getTime()) ? null : date;
16
+ }
17
+
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * Check if a date/timestamp has already passed (expired).
23
+ */
24
+ function isPast(dateLike: unknown): boolean {
25
+ if (!dateLike) return false;
26
+ const date = toValidDate(dateLike);
27
+ if (!date) return false;
28
+ return date.getTime() <= Date.now();
29
+ }
30
+
31
+ /**
32
+ * Check if a date/timestamp is within a specified number of milliseconds from now.
33
+ * Useful for detecting tokens approaching expiry.
34
+ */
35
+ function isWithinMs(dateLike: unknown, windowMs: number): boolean {
36
+ if (!dateLike) return false;
37
+ const date = toValidDate(dateLike);
38
+ if (!date) return false;
39
+ return date.getTime() - Date.now() <= windowMs;
40
+ }
41
+
42
+ /**
43
+ * Proactive refresh window: refresh access tokens 5 minutes before expiry.
44
+ * Prevents users from being signed out when their token expires.
45
+ * Better Auth's default is only ~5 seconds, which is too late.
46
+ */
47
+ const ACCESS_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000;
48
+
49
+ type EntraAccount = {
50
+ id?: string;
51
+ providerId?: string;
52
+ idToken?: string | null;
53
+ accessToken?: string | null;
54
+ refreshToken?: string | null;
55
+ accessTokenExpiresAt?: Date | null;
56
+ refreshTokenExpiresAt?: Date | null;
57
+ scope?: string | null;
58
+ };
59
+
60
+ async function decryptStoredOAuthToken(
61
+ token: string | null | undefined,
62
+ context: Parameters<typeof decryptOAuthToken>[1],
63
+ ): Promise<string> {
64
+ if (!token) return "";
65
+
66
+ try {
67
+ return await decryptOAuthToken(token, context);
68
+ } catch {
69
+ return "";
70
+ }
71
+ }
72
+
73
+ async function resolveEntraRefreshProvider(
74
+ socialProvidersSource: unknown,
75
+ ): Promise<{
76
+ id?: string;
77
+ refreshAccessToken?: (token: string) => Promise<{
78
+ accessToken?: string;
79
+ refreshToken?: string;
80
+ accessTokenExpiresAt?: Date | null;
81
+ refreshTokenExpiresAt?: Date | null;
82
+ idToken?: string;
83
+ scopes?: string[];
84
+ } | null>;
85
+ } | null> {
86
+ const socialProviders =
87
+ typeof socialProvidersSource === "function"
88
+ ? await socialProvidersSource()
89
+ : await Promise.resolve(socialProvidersSource);
90
+
91
+ if (Array.isArray(socialProviders)) {
92
+ return (
93
+ socialProviders.find(
94
+ (provider: { id?: string }) => provider?.id === "entra-external-id",
95
+ ) || null
96
+ );
97
+ }
98
+
99
+ if (socialProviders && typeof socialProviders === "object") {
100
+ return (
101
+ (
102
+ socialProviders as Record<
103
+ string,
104
+ {
105
+ id?: string;
106
+ refreshAccessToken?: (token: string) => Promise<{
107
+ accessToken?: string;
108
+ refreshToken?: string;
109
+ accessTokenExpiresAt?: Date | null;
110
+ refreshTokenExpiresAt?: Date | null;
111
+ idToken?: string;
112
+ scopes?: string[];
113
+ } | null>;
114
+ }
115
+ >
116
+ )["entra-external-id"] || null
117
+ );
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ async function updateStoredEntraTokens(
124
+ account: EntraAccount,
125
+ refreshed: {
126
+ accessToken?: string;
127
+ refreshToken?: string;
128
+ accessTokenExpiresAt?: Date | null;
129
+ refreshTokenExpiresAt?: Date | null;
130
+ idToken?: string;
131
+ scopes?: string[];
132
+ } | null,
133
+ context: Parameters<typeof decryptOAuthToken>[1] & {
134
+ internalAdapter: {
135
+ updateAccount: (
136
+ accountId: string,
137
+ data: Record<string, unknown>,
138
+ ) => Promise<unknown>;
139
+ };
140
+ },
141
+ ): Promise<void> {
142
+ if (!account.id || !refreshed) return;
143
+
144
+ const updatedAccessToken = refreshed.accessToken
145
+ ? await setTokenUtil(refreshed.accessToken, context)
146
+ : account.accessToken;
147
+ const updatedRefreshToken = refreshed.refreshToken
148
+ ? await setTokenUtil(refreshed.refreshToken, context)
149
+ : account.refreshToken;
150
+
151
+ await context.internalAdapter.updateAccount(account.id, {
152
+ accessToken: updatedAccessToken,
153
+ refreshToken: updatedRefreshToken,
154
+ accessTokenExpiresAt:
155
+ refreshed.accessTokenExpiresAt || account.accessTokenExpiresAt,
156
+ refreshTokenExpiresAt:
157
+ refreshed.refreshTokenExpiresAt || account.refreshTokenExpiresAt,
158
+ idToken: refreshed.idToken || account.idToken,
159
+ scope: refreshed.scopes?.join(",") || account.scope,
160
+ });
161
+ }
162
+
163
+ async function refreshEntraTokens(
164
+ account: EntraAccount,
165
+ context: Parameters<typeof decryptOAuthToken>[1] & {
166
+ socialProviders: unknown;
167
+ internalAdapter: {
168
+ updateAccount: (
169
+ accountId: string,
170
+ data: Record<string, unknown>,
171
+ ) => Promise<unknown>;
172
+ };
173
+ },
174
+ ): Promise<{
175
+ accessToken?: string;
176
+ refreshToken?: string;
177
+ accessTokenExpiresAt?: Date | null;
178
+ refreshTokenExpiresAt?: Date | null;
179
+ idToken?: string;
180
+ scopes?: string[];
181
+ } | null> {
182
+ const provider = await resolveEntraRefreshProvider(context.socialProviders);
183
+ if (!provider?.refreshAccessToken) {
184
+ console.warn(
185
+ "[better-auth] proactive refresh skipped: provider not resolvable",
186
+ );
187
+ return null;
188
+ }
189
+
190
+ const refreshToken = await decryptOAuthToken(
191
+ account.refreshToken || "",
192
+ context,
193
+ );
194
+ const refreshed = await provider.refreshAccessToken(refreshToken);
195
+ await updateStoredEntraTokens(account, refreshed, context);
196
+ return refreshed;
197
+ }
4
198
 
5
199
  const tenantName = process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_TENANT_NAME || "";
6
200
  const loginAuthority = `https://${tenantName}.ciamlogin.com/${tenantName}.onmicrosoft.com`;
7
201
 
8
- export const auth = betterAuth({
202
+ const globalForAuthDb = globalThis as typeof globalThis & {
203
+ storefrontAuthDbPool?: Pool;
204
+ };
205
+
206
+ const authDbPool =
207
+ globalForAuthDb.storefrontAuthDbPool ||
208
+ new Pool({
209
+ connectionString: process.env.AUTH_DB_URL,
210
+ });
211
+
212
+ if (process.env.NODE_ENV !== "production") {
213
+ globalForAuthDb.storefrontAuthDbPool = authDbPool;
214
+ }
215
+
216
+ const authConfig = {
9
217
  secret: process.env.BETTER_AUTH_SECRET,
10
- baseURL: process.env.BETTER_AUTH_URL || process.env.NEXTAUTH_URL,
218
+ baseURL: process.env.BETTER_AUTH_URL,
219
+ database: authDbPool,
220
+ // Required for native screen login: Entra ROPC validates the password,
221
+ // Better Auth uses email/password internally only to create a session.
222
+ // The entra-external-id account with real OAuth tokens is upserted separately.
223
+ emailAndPassword: {
224
+ enabled: true,
225
+ },
11
226
  session: {
12
227
  cookieCache: {
13
- enabled: true,
14
- maxAge: 60 * 60, // 1 hour
15
- strategy: "jwe",
16
- refreshCache: true,
228
+ enabled: false,
17
229
  },
18
230
  },
19
231
  account: {
@@ -24,10 +236,10 @@ export const auth = betterAuth({
24
236
  genericOAuth({
25
237
  config: [
26
238
  {
239
+ // Entra External ID is configured as public client.
240
+ // No client secret is sent during refresh (Entra rejects it).
27
241
  providerId: "entra-external-id",
28
242
  clientId: process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_ID || "",
29
- clientSecret:
30
- process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CLIENT_SECRET || "",
31
243
  discoveryUrl: `${loginAuthority}/v2.0/.well-known/openid-configuration`,
32
244
  scopes: (
33
245
  process.env.NEXT_PUBLIC_ENTRA_EXTERNAL_ID_CUSTOM_SCOPES ||
@@ -60,30 +272,85 @@ export const auth = betterAuth({
60
272
  },
61
273
  ],
62
274
  }),
275
+ // Custom session plugin that:
276
+ // 1. Reads Entra account tokens from public.account table
277
+ // 2. Attempts proactive refresh 5 minutes before expiry
278
+ // 3. Prevents unexpected signout by keeping current token until truly expired
63
279
  customSession(async ({ user, session }, ctx) => {
64
280
  let accessToken = "";
281
+ let graphAccessToken = "";
65
282
  try {
66
- const accounts = await ctx.context.internalAdapter.findAccounts(
283
+ const accounts = (await ctx.context.internalAdapter.findAccounts(
67
284
  user.id,
68
- );
285
+ )) as EntraAccount[] | undefined;
69
286
  const account = accounts?.find(
70
- (a) => a.providerId === "entra-external-id",
287
+ (storedAccount) => storedAccount.providerId === "entra-external-id",
71
288
  );
72
- // For Entra External ID, use id_token as the access token
73
- accessToken = account?.idToken || account?.accessToken || "";
289
+ if (!account) {
290
+ return {
291
+ user,
292
+ session: {
293
+ ...session,
294
+ accessToken,
295
+ graphAccessToken,
296
+ },
297
+ };
298
+ }
299
+
300
+ const tokenExpired = isPast(account.accessTokenExpiresAt);
301
+ const shouldRefreshUpfront = isWithinMs(
302
+ account.accessTokenExpiresAt,
303
+ ACCESS_TOKEN_REFRESH_WINDOW_MS,
304
+ );
305
+ const currentIdToken = account.idToken || "";
306
+ const currentGraphToken = await decryptStoredOAuthToken(
307
+ account.accessToken,
308
+ ctx.context,
309
+ );
310
+
311
+ // Keep ID token for D365 and access token for Graph while valid.
312
+ accessToken = tokenExpired ? "" : currentIdToken;
313
+ graphAccessToken = tokenExpired ? "" : currentGraphToken;
314
+
315
+ // Attempt proactive refresh if token is within 5-minute window and a refresh token exists.
316
+ // This ensures users stay signed in without waiting until token expires.
317
+ if (account.refreshToken && shouldRefreshUpfront) {
318
+ try {
319
+ const refreshed = await refreshEntraTokens(account, ctx.context);
320
+ if (refreshed) {
321
+ // Keep ID token for D365 and access token for Graph APIs.
322
+ accessToken = refreshed.idToken || account.idToken || accessToken;
323
+ graphAccessToken = refreshed.accessToken || graphAccessToken;
324
+ }
325
+ } catch (error) {
326
+ // Preserve the current token and avoid signing out early on refresh failures.
327
+ console.warn(
328
+ "[better-auth] proactive refresh failed for entra-external-id",
329
+ error,
330
+ );
331
+ }
332
+ }
74
333
  } catch {
75
- // Account data may not be accessible in all modes
334
+ // Account data may not be accessible in all modes.
335
+ // Keep existing empty default if we cannot read account data.
76
336
  }
77
337
  return {
78
338
  user,
79
339
  session: {
80
340
  ...session,
81
341
  accessToken,
342
+ graphAccessToken,
82
343
  },
83
344
  };
84
345
  }),
85
346
  nextCookies(), // must be last plugin
86
347
  ],
87
- });
348
+ } satisfies Parameters<typeof betterAuth>[0];
349
+
350
+ const auth: Auth<BetterAuthOptions> = betterAuth(
351
+ authConfig as BetterAuthOptions,
352
+ );
353
+
354
+ export { auth };
88
355
 
89
356
  export type Session = typeof auth.$Infer.Session;
File without changes
File without changes
File without changes
File without changes
@@ -31,7 +31,9 @@ export default async function ProductSectionFullWidth(
31
31
 
32
32
  const skus = data?.ProductWrapper?.map((element) => element?.product?.sku);
33
33
 
34
- const e4Products = await getProductByIds.fetchData(queryClient, { id: skus });
34
+ const e4Products = (await getProductByIds.fetchData(queryClient, {
35
+ id: skus,
36
+ })) as SimpleProduct[] | undefined;
35
37
 
36
38
  if (!e4Products?.length) {
37
39
  return null;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -40,7 +40,12 @@ const CategoryList = ({
40
40
  children,
41
41
  }: RootProps) => {
42
42
  const { data: fetchedCategories } = getCategories.useSuspenseData();
43
- const cats = categories ?? fetchedCategories ?? [];
43
+ let cats: typeof categories = [];
44
+ if (Array.isArray(categories)) {
45
+ cats = categories;
46
+ } else if (Array.isArray(fetchedCategories)) {
47
+ cats = fetchedCategories;
48
+ }
44
49
 
45
50
  const megaMenuRenderers: Record<
46
51
  MegaMenuType,
@@ -69,7 +74,8 @@ CategoryList.Item = ({ category }: ItemProps) => {
69
74
  if (!ctx) return null;
70
75
  const { megaMenuRenderers } = ctx;
71
76
  const { RecordId, Name, Url, Children } = category;
72
- const hasChildren = !!Children?.length;
77
+ const childCategories = Array.isArray(Children) ? Children : [];
78
+ const hasChildren = childCategories.length > 0;
73
79
 
74
80
  // Sub menu can also be "categories/mega-menu/mega-menu-1" or "mega-menu-2",
75
81
  // Mega menus have submenu items listed in columns and also have option to show banners(offers).
@@ -83,9 +89,7 @@ CategoryList.Item = ({ category }: ItemProps) => {
83
89
  title={Name ?? ""}
84
90
  caret={hasChildren}
85
91
  render={
86
- Children && Children.length > 0
87
- ? megaMenuRenderers[menuType]?.(Children)
88
- : null
92
+ hasChildren ? megaMenuRenderers[menuType]?.(childCategories) : null
89
93
  }
90
94
  />
91
95
  );