@evenicanpm/storefront-core 2.4.0 → 2.4.1
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/CHANGELOG.md +9 -0
- package/package.json +15 -2
- package/src/api-manager/datasources/d365/d365-address.datasource.ts +142 -66
- package/src/api-manager/datasources/d365/d365-user.datasource.ts +109 -37
- package/src/api-manager/datasources/d365/utils/get-context-cookie.ts +44 -10
- package/src/api-manager/lib/get-graphql-client.ts +35 -7
- package/src/api-manager/services/create-query.ts +36 -3
- package/src/api-manager/services/get-query-client.ts +10 -0
- package/src/auth/better-auth.ts +282 -15
- package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
- package/src/components/categories/category-list/category-list.tsx +9 -5
- package/src/components/header/__tests__/user.test.tsx +5 -107
- package/src/components/header/components/user.tsx +72 -45
- package/src/hooks/use-nextauth-session.ts +0 -33
- package/src/lib/auth-client.ts +14 -0
- package/src/lib/auth.ts +4 -0
- package/src/lib/entra-native-auth.ts +138 -0
- package/src/lib/native-session.ts +434 -0
- package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
- package/src/pages/account/profile/profile-form.tsx +285 -0
- package/src/pages/account/profile/profile-validation.ts +52 -0
- package/src/pages/auth/auth-activate-page.tsx +509 -0
- package/src/pages/auth/auth-layout.tsx +73 -0
- package/src/pages/auth/auth-login-page.tsx +241 -0
- package/src/pages/auth/auth-reset-password-page.tsx +487 -0
- package/src/pages/auth/compound/auth-form.tsx +182 -0
- package/src/pages/auth/compound/auth-pages.tsx +21 -0
- package/src/pages/auth/lib/friendly-error.ts +70 -0
- package/src/pages/auth/lib/index.ts +2 -0
- package/src/pages/auth/lib/types.ts +5 -0
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
- package/tsconfig.json +20 -14
- package/src/auth/msal.ts +0 -65
- package/src/pages/account/profile/profile-button.test.tsx +0 -59
- package/src/pages/account/profile/profile-button.tsx +0 -51
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evenicanpm/storefront-core",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "Core module for D365/e4 Headless Storefront",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -18,8 +18,20 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@evenicanpm/cms": "workspace:*",
|
|
20
20
|
"@evenicanpm/ui": "workspace:*",
|
|
21
|
+
"react-dropzone": "^14.3.8",
|
|
22
|
+
"react-slick": "^0.30.1",
|
|
23
|
+
"slick-carousel": "^1.8.1",
|
|
21
24
|
"strapi-sdk-js": "^3.0.0",
|
|
22
|
-
"clsx": "^2.1.1"
|
|
25
|
+
"clsx": "^2.1.1",
|
|
26
|
+
"react-icons": "^5.4.0",
|
|
27
|
+
"simplebar-react": "^3.3.0",
|
|
28
|
+
"formik": "^2.4.6",
|
|
29
|
+
"formik-validator-zod": "^2.0.1",
|
|
30
|
+
"currency.js": "^2.0.4",
|
|
31
|
+
"@hookform/resolvers": "^3.9.0",
|
|
32
|
+
"react-hook-form": "^7.53.0",
|
|
33
|
+
"yup": "^1.3.3",
|
|
34
|
+
"nuqs": "^2.8.9"
|
|
23
35
|
},
|
|
24
36
|
"devDependencies": {
|
|
25
37
|
"@emotion/react": "catalog:",
|
|
@@ -62,6 +74,7 @@
|
|
|
62
74
|
"next": "catalog:",
|
|
63
75
|
"next-intl": "catalog:",
|
|
64
76
|
"object-mapper": "^6.2.0",
|
|
77
|
+
"pg": "^8.8.0",
|
|
65
78
|
"react": "catalog:",
|
|
66
79
|
"react-dom": "catalog:",
|
|
67
80
|
"set-cookie-parser": "^2.7.1",
|
|
@@ -7,10 +7,13 @@ import {
|
|
|
7
7
|
type CountryRegionInfo,
|
|
8
8
|
type DeliveryOption,
|
|
9
9
|
FilterDeliveryModeOption,
|
|
10
|
+
type ICallerContext,
|
|
11
|
+
type IContext,
|
|
10
12
|
type StateProvinceInfo,
|
|
11
13
|
} from "@msdyn365-commerce/retail-proxy";
|
|
12
14
|
import { getDeliveryOptionsAsync } from "@msdyn365-commerce/retail-proxy/dist/DataActions/CartsDataActions.g";
|
|
13
15
|
import {
|
|
16
|
+
createCustomerAsync,
|
|
14
17
|
readAsync,
|
|
15
18
|
updateAsync,
|
|
16
19
|
} from "@msdyn365-commerce/retail-proxy/dist/DataActions/CustomersDataActions.g";
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
getCountryRegionsByLanguageIdAsync,
|
|
19
22
|
getStateProvincesAsync,
|
|
20
23
|
} from "@msdyn365-commerce/retail-proxy/dist/DataActions/StoreOperationsDataActions.g";
|
|
24
|
+
import jwt from "jsonwebtoken";
|
|
21
25
|
import type {
|
|
22
26
|
Address,
|
|
23
27
|
GetCountriesInput,
|
|
@@ -25,88 +29,160 @@ import type {
|
|
|
25
29
|
GetStatesInput,
|
|
26
30
|
} from "@/schemas/address.schema";
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
type IdTokenClaims = jwt.JwtPayload & {
|
|
33
|
+
emails?: string[];
|
|
34
|
+
email?: string;
|
|
35
|
+
preferred_username?: string;
|
|
36
|
+
upn?: string;
|
|
37
|
+
unique_name?: string;
|
|
38
|
+
given_name?: string;
|
|
39
|
+
family_name?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function extractEmailFromClaims(token: IdTokenClaims): string | undefined {
|
|
43
|
+
return [
|
|
44
|
+
Array.isArray(token.emails) ? token.emails[0] : undefined,
|
|
45
|
+
token.email,
|
|
46
|
+
token.preferred_username,
|
|
47
|
+
token.upn,
|
|
48
|
+
token.unique_name,
|
|
49
|
+
]
|
|
50
|
+
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
|
51
|
+
.find((v) => v.includes("@"));
|
|
52
|
+
}
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
function decodeIdTokenClaims(token: string): IdTokenClaims {
|
|
55
|
+
const decoded = jwt.decode(token);
|
|
56
|
+
if (!decoded || typeof decoded !== "object") {
|
|
57
|
+
return {} as IdTokenClaims;
|
|
58
|
+
}
|
|
59
|
+
return decoded;
|
|
60
|
+
}
|
|
38
61
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
const added = updatedCustomer.Addresses?.find((a) =>
|
|
46
|
-
prevAddresses.every((old) => old.RecordId !== a.RecordId),
|
|
47
|
-
);
|
|
48
|
-
if (added?.RecordId == null) {
|
|
49
|
-
throw new Error("Failed to create new address");
|
|
50
|
-
}
|
|
62
|
+
class D365AddressDatasource extends D365DatasourceBase implements AddressApi {
|
|
63
|
+
private getRetailContext(context: ICallerContext): IContext {
|
|
64
|
+
return context as unknown as IContext;
|
|
65
|
+
}
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
67
|
+
private async getOrCreateCustomer(context: ICallerContext) {
|
|
68
|
+
try {
|
|
69
|
+
return await readAsync(this.getRetailContext(context), "");
|
|
70
|
+
} catch {
|
|
71
|
+
const token = decodeIdTokenClaims(
|
|
72
|
+
context.requestContext?.user?.token || "",
|
|
73
|
+
);
|
|
74
|
+
const email = extractEmailFromClaims(token);
|
|
75
|
+
if (!email) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Cannot resolve or create customer profile: missing email in token claims.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return createCustomerAsync(this.getRetailContext(context), {
|
|
82
|
+
AccountNumber: "",
|
|
83
|
+
Email: email,
|
|
84
|
+
FirstName: token.given_name,
|
|
85
|
+
LastName: token.family_name || ".",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
56
88
|
}
|
|
57
89
|
|
|
58
|
-
async
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
90
|
+
async createAddress(address: Address): Promise<Address> {
|
|
91
|
+
try {
|
|
92
|
+
const context = await getContextFromCookie();
|
|
93
|
+
const customer = await this.getOrCreateCustomer(context);
|
|
94
|
+
const accountNumber = customer.AccountNumber;
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// biome-ignore lint: api datasources should not be typed
|
|
68
|
-
const updatedRecord = { ...(address as any) };
|
|
96
|
+
const prevAddresses = customer.Addresses ?? [];
|
|
97
|
+
// biome-ignore lint: api datasources should not be typed
|
|
98
|
+
customer.Addresses = [...prevAddresses, address as any];
|
|
69
99
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...customer
|
|
73
|
-
|
|
100
|
+
// biome-ignore lint: api datasources should not be typed
|
|
101
|
+
const updatedCustomer = await updateAsync(context as any, {
|
|
102
|
+
...customer,
|
|
103
|
+
AccountNumber: accountNumber,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const added = updatedCustomer.Addresses?.find((a) =>
|
|
107
|
+
prevAddresses.every((old) => old.RecordId !== a.RecordId),
|
|
108
|
+
);
|
|
109
|
+
if (added?.RecordId == null) {
|
|
110
|
+
throw new Error("Failed to create new address");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...added,
|
|
115
|
+
Id: added.RecordId.toString(),
|
|
74
116
|
};
|
|
75
|
-
}
|
|
76
|
-
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const message =
|
|
119
|
+
error instanceof Error ? error.message : JSON.stringify(error);
|
|
120
|
+
throw new Error(`createAddress failed: ${message}`);
|
|
77
121
|
}
|
|
122
|
+
}
|
|
78
123
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
124
|
+
async updateAddress(address: Address): Promise<Address> {
|
|
125
|
+
try {
|
|
126
|
+
const context = await getContextFromCookie();
|
|
127
|
+
const customer = await this.getOrCreateCustomer(context);
|
|
128
|
+
customer.Addresses = customer.Addresses ?? [];
|
|
129
|
+
|
|
130
|
+
const idx = customer.Addresses.findIndex(
|
|
131
|
+
(a) => a.RecordId?.toString() === address.Id,
|
|
85
132
|
);
|
|
86
|
-
|
|
133
|
+
// biome-ignore lint: api datasources should not be typed
|
|
134
|
+
const updatedRecord = { ...(address as any) };
|
|
87
135
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
136
|
+
if (idx >= 0) {
|
|
137
|
+
customer.Addresses[idx] = {
|
|
138
|
+
...customer.Addresses[idx],
|
|
139
|
+
...updatedRecord,
|
|
140
|
+
};
|
|
141
|
+
} else {
|
|
142
|
+
customer.Addresses.push(updatedRecord);
|
|
143
|
+
}
|
|
93
144
|
|
|
94
|
-
async deleteAddress(addressId: string): Promise<string> {
|
|
95
|
-
const context = await getContextFromCookie();
|
|
96
|
-
// biome-ignore lint: api datasources should not be typed
|
|
97
|
-
const customer = await readAsync(context as any, "");
|
|
98
|
-
customer.Addresses = customer.Addresses ?? [];
|
|
99
|
-
|
|
100
|
-
const rec = customer.Addresses.find(
|
|
101
|
-
(a) => a.RecordId?.toString() === addressId,
|
|
102
|
-
);
|
|
103
|
-
if (rec) {
|
|
104
|
-
rec.Deactivate = true;
|
|
105
145
|
// biome-ignore lint: api datasources should not be typed
|
|
106
|
-
await updateAsync(context as any, customer);
|
|
146
|
+
const updatedCustomer = await updateAsync(context as any, customer);
|
|
147
|
+
const rec = updatedCustomer?.Addresses?.[idx];
|
|
148
|
+
if (rec?.RecordId == null) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Failed to update address Id=${address.Id}. Updated address record with a matching id not found.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
...rec,
|
|
156
|
+
Id: rec.RecordId.toString(),
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const message =
|
|
160
|
+
error instanceof Error ? error.message : JSON.stringify(error);
|
|
161
|
+
throw new Error(`updateAddress failed: ${message}`);
|
|
107
162
|
}
|
|
163
|
+
}
|
|
108
164
|
|
|
109
|
-
|
|
165
|
+
async deleteAddress(addressId: string): Promise<string> {
|
|
166
|
+
try {
|
|
167
|
+
const context = await getContextFromCookie();
|
|
168
|
+
const customer = await this.getOrCreateCustomer(context);
|
|
169
|
+
customer.Addresses = customer.Addresses ?? [];
|
|
170
|
+
|
|
171
|
+
const rec = customer.Addresses.find(
|
|
172
|
+
(a) => a.RecordId?.toString() === addressId,
|
|
173
|
+
);
|
|
174
|
+
if (rec) {
|
|
175
|
+
rec.Deactivate = true;
|
|
176
|
+
// biome-ignore lint: api datasources should not be typed
|
|
177
|
+
await updateAsync(context as any, customer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return addressId;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const message =
|
|
183
|
+
error instanceof Error ? error.message : JSON.stringify(error);
|
|
184
|
+
throw new Error(`deleteAddress failed: ${message}`);
|
|
185
|
+
}
|
|
110
186
|
}
|
|
111
187
|
|
|
112
188
|
async getCountries(input: GetCountriesInput): Promise<CountryRegionInfo[]> {
|
|
@@ -46,12 +46,30 @@ import type {
|
|
|
46
46
|
type IdTokenClaims = jwt.JwtPayload & {
|
|
47
47
|
emails?: string[];
|
|
48
48
|
email?: string;
|
|
49
|
+
preferred_username?: string;
|
|
50
|
+
upn?: string;
|
|
51
|
+
unique_name?: string;
|
|
49
52
|
given_name?: string;
|
|
50
53
|
family_name?: string;
|
|
51
54
|
};
|
|
52
55
|
|
|
56
|
+
function extractEmailFromClaims(token: IdTokenClaims): string | undefined {
|
|
57
|
+
return [
|
|
58
|
+
Array.isArray(token.emails) ? token.emails[0] : undefined,
|
|
59
|
+
token.email,
|
|
60
|
+
token.preferred_username,
|
|
61
|
+
token.upn,
|
|
62
|
+
token.unique_name,
|
|
63
|
+
]
|
|
64
|
+
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
|
65
|
+
.find((v) => v.includes("@"));
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
function decodeIdTokenClaims(token: string): IdTokenClaims {
|
|
54
69
|
const decoded = jwt.decode(token);
|
|
70
|
+
if (!decoded || typeof decoded !== "object") {
|
|
71
|
+
return {} as IdTokenClaims;
|
|
72
|
+
}
|
|
55
73
|
return decoded as IdTokenClaims;
|
|
56
74
|
}
|
|
57
75
|
|
|
@@ -63,39 +81,89 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
|
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
async updateUser(user: User): Promise<User> {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
return tracer.startActiveSpan("d365.updateUser", async (span) => {
|
|
85
|
+
try {
|
|
86
|
+
const context = this.context;
|
|
87
|
+
|
|
88
|
+
let existing: Customer;
|
|
89
|
+
try {
|
|
90
|
+
existing = await readAsync(this.getRetailContext(context), "");
|
|
91
|
+
} catch {
|
|
92
|
+
const token = decodeIdTokenClaims(
|
|
93
|
+
context.requestContext.user.token || "",
|
|
94
|
+
);
|
|
95
|
+
const customerEmail = user.Email || extractEmailFromClaims(token);
|
|
96
|
+
|
|
97
|
+
if (!customerEmail) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"Cannot create D365 customer: no email provided in payload or token claims.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
existing = await createCustomerAsync(this.getRetailContext(context), {
|
|
104
|
+
AccountNumber: "",
|
|
105
|
+
Email: customerEmail,
|
|
106
|
+
FirstName: user.FirstName || token.given_name,
|
|
107
|
+
LastName: user.LastName || token.family_name || ".",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (user.FirstName !== undefined) existing.FirstName = user.FirstName;
|
|
112
|
+
if (user.LastName !== undefined) existing.LastName = user.LastName;
|
|
113
|
+
|
|
114
|
+
if (user.DefaultShippingAddressId !== undefined) {
|
|
115
|
+
existing.Addresses ??= [];
|
|
116
|
+
existing.Addresses = existing.Addresses.map((addr) => ({
|
|
117
|
+
...addr,
|
|
118
|
+
IsPrimary:
|
|
119
|
+
addr.RecordId?.toString() === user.DefaultShippingAddressId,
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const propKey = "DefaultShippingAddressId";
|
|
123
|
+
existing.ExtensionProperties = existing.ExtensionProperties ?? [];
|
|
124
|
+
existing.ExtensionProperties = existing.ExtensionProperties.filter(
|
|
125
|
+
(p: CommerceProperty) => p.Key !== propKey,
|
|
126
|
+
);
|
|
127
|
+
existing.ExtensionProperties.push({
|
|
128
|
+
Key: propKey,
|
|
129
|
+
Value: {
|
|
130
|
+
StringValue: user.DefaultShippingAddressId,
|
|
131
|
+
} as NonNullable<CommerceProperty["Value"]>,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const updated: Customer = await updateAsync(
|
|
136
|
+
this.getRetailContext(context),
|
|
137
|
+
existing,
|
|
138
|
+
);
|
|
139
|
+
const mappedAddresses = (updated.Addresses ?? []).map((a) => ({
|
|
140
|
+
...a,
|
|
141
|
+
Id: a.RecordId?.toString() ?? "",
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
span.setAttributes({
|
|
145
|
+
"d365.update_user.account_number": updated.AccountNumber ?? "",
|
|
146
|
+
"d365.update_user.first_name": updated.FirstName ?? "",
|
|
147
|
+
"d365.update_user.last_name": updated.LastName ?? "",
|
|
148
|
+
});
|
|
149
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...updated,
|
|
153
|
+
Addresses: mappedAddresses,
|
|
154
|
+
} as User;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message =
|
|
157
|
+
error instanceof Error ? error.message : JSON.stringify(error);
|
|
158
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
159
|
+
span.recordException(
|
|
160
|
+
error instanceof Error ? error : new Error(message),
|
|
161
|
+
);
|
|
162
|
+
throw new Error(`updateUser failed: ${message}`);
|
|
163
|
+
} finally {
|
|
164
|
+
span.end();
|
|
165
|
+
}
|
|
86
166
|
});
|
|
87
|
-
const updated: Customer = await updateAsync(
|
|
88
|
-
this.getRetailContext(context),
|
|
89
|
-
existing,
|
|
90
|
-
);
|
|
91
|
-
const mappedAddresses = (updated.Addresses ?? []).map((a) => ({
|
|
92
|
-
...a,
|
|
93
|
-
Id: a.RecordId?.toString() ?? "",
|
|
94
|
-
}));
|
|
95
|
-
return {
|
|
96
|
-
...updated,
|
|
97
|
-
Addresses: mappedAddresses,
|
|
98
|
-
} as User;
|
|
99
167
|
}
|
|
100
168
|
|
|
101
169
|
async getUser(): Promise<User | null> {
|
|
@@ -115,7 +183,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
|
|
|
115
183
|
const token = decodeIdTokenClaims(context.requestContext.user.token);
|
|
116
184
|
customer = await createCustomerAsync(this.getRetailContext(context), {
|
|
117
185
|
AccountNumber: "",
|
|
118
|
-
Email:
|
|
186
|
+
Email: extractEmailFromClaims(token),
|
|
119
187
|
FirstName: token.given_name,
|
|
120
188
|
LastName: token.family_name || ".",
|
|
121
189
|
});
|
|
@@ -325,11 +393,15 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
|
|
|
325
393
|
async (span) => {
|
|
326
394
|
const context = this.context;
|
|
327
395
|
const id = input.Id;
|
|
328
|
-
|
|
396
|
+
// Get catalogId from apiSettings, with fallback to default catalog ID (1)
|
|
397
|
+
// D365 requires a valid CatalogId when multiple catalogs feature is enabled
|
|
398
|
+
const catalogId =
|
|
399
|
+
this.context.requestContext.apiSettings.catalogId || 1;
|
|
329
400
|
|
|
330
401
|
const productListLines = input.ProductListLines.map((line) => ({
|
|
331
402
|
...line,
|
|
332
|
-
CatalogId
|
|
403
|
+
// Always set CatalogId - use from input if present, otherwise use the resolved catalogId
|
|
404
|
+
CatalogId: line.CatalogId || catalogId,
|
|
333
405
|
}));
|
|
334
406
|
console.log(productListLines);
|
|
335
407
|
try {
|
|
@@ -345,7 +417,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
|
|
|
345
417
|
};
|
|
346
418
|
return wishlist;
|
|
347
419
|
} catch (error) {
|
|
348
|
-
throw new Error(`Error adding to wishlist: ${error}`);
|
|
420
|
+
throw new Error(`Error adding to wishlist: ${JSON.stringify(error)}`);
|
|
349
421
|
} finally {
|
|
350
422
|
span.end();
|
|
351
423
|
}
|
|
@@ -364,7 +436,7 @@ class D365UserDatasource extends D365DatasourceBase implements UserApi {
|
|
|
364
436
|
productListLines,
|
|
365
437
|
);
|
|
366
438
|
} catch (error) {
|
|
367
|
-
throw new Error(`Error removing from wishlist: ${error}`);
|
|
439
|
+
throw new Error(`Error removing from wishlist: ${JSON.stringify(error)}`);
|
|
368
440
|
}
|
|
369
441
|
}
|
|
370
442
|
|
|
@@ -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
|
-
|
|
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
|
|
48
|
+
const session = await auth.api.getSession({
|
|
32
49
|
headers: await getHeaders(),
|
|
33
|
-
body: { providerId: "entra-external-id" },
|
|
34
50
|
});
|
|
35
|
-
const accessToken =
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|