@evenicanpm/storefront-core 2.3.1 → 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 +17 -0
- package/package.json +42 -9
- package/src/api-manager/datasources/d365/d365-address.datasource.ts +148 -61
- package/src/api-manager/datasources/d365/d365-cart.datasource.ts +23 -1
- package/src/api-manager/datasources/d365/d365-order.datasource.ts +6 -1
- 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/index.ts +2 -1
- package/src/api-manager/lib/get-graphql-client.ts +35 -7
- package/src/api-manager/schemas/product.schema.ts +1 -1
- 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/block-manager.tsx +1 -1
- package/src/cms/blocks/components/product-section-fullwidth/index.tsx +3 -1
- package/src/cms/endpoints.ts +7 -2
- package/src/components/BazaarMenu.tsx +1 -1
- package/src/components/Typography.tsx +4 -2
- package/src/components/carousel-cards/carousel-card-1/CarouselCard1.stories.tsx +1 -1
- package/src/components/categories/category-list/category-list.tsx +9 -5
- package/src/components/categories/category-menu.tsx +1 -1
- package/src/components/countries-input.tsx +4 -0
- package/src/components/header/__tests__/user.test.tsx +5 -107
- package/src/components/header/components/user.tsx +72 -45
- package/src/components/header/sticky-header.tsx +0 -1
- package/src/components/navbar/mobile-menu/mobile-menu.test.tsx +1 -1
- package/src/components/navbar/utils/transform-nav.test.ts +1 -1
- package/src/components/navbar/utils/transform-nav.ts +1 -1
- package/src/components/product-cards/product-card/product-card.tsx +5 -2
- package/src/components/product-cards/product-card/styles/index.ts +1 -5
- package/src/components/products-view/compound/products-grid-view.tsx +5 -1
- package/src/components/products-view/compound/products-list-view.tsx +5 -1
- package/src/global.d.ts +3 -0
- 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/addresses/address-form.tsx +20 -2
- 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/account/wishlist/wishlist-item.tsx +1 -2
- 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/cart/cart-item.tsx +1 -7
- package/src/pages/checkout/checkout-alt-form/checkout-form.tsx +14 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/address-card.tsx +5 -3
- package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
- package/src/pages/checkout/checkout-alt-form/steps/address/new-address-form.tsx +1 -1
- package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
- package/src/pages/confirmation/ordered-products.tsx +3 -1
- package/src/pages/product-details/bopis/find-in-store-modal.tsx +4 -4
- package/src/pages/product-details/bopis/pickup-option-select.tsx +2 -2
- package/src/pages/product-details/bopis/search-header.tsx +2 -2
- package/src/pages/product-details/product-intro/compound/context.ts +8 -3
- package/src/pages/product-details/product-intro/compound/product-info.tsx +25 -19
- package/src/pages/product-list/product-list-view.tsx +2 -1
- package/src/providers/nav-provider/index.tsx +1 -1
- package/src/providers/nav-provider/utils/createLink.ts +1 -1
- package/tsconfig.json +20 -15
- package/__mocks__/countries.ts +0 -11
- package/__mocks__/create-mutation.ts +0 -68
- package/__mocks__/create-query.ts +0 -94
- package/__mocks__/data/categories.json +0 -795
- package/__mocks__/get-product-by-id.ts +0 -6
- package/__mocks__/next-auth-react.ts +0 -9
- package/__mocks__/next-font.js +0 -4
- package/__mocks__/next-headers.js +0 -13
- package/__mocks__/next-image.tsx +0 -18
- package/__mocks__/next-link.js +0 -19
- package/__mocks__/next-navigation.ts +0 -29
- package/__mocks__/product-dimensions.ts +0 -635
- package/__mocks__/products.ts +0 -533
- package/__mocks__/query-client.ts +0 -3
- package/__mocks__/wishlists.json +0 -408
- package/src/auth/msal.ts +0 -68
- package/src/cms/blog.ts +0 -229
- package/src/cms/interfaces/navigation.ts +0 -52
- package/src/cms/interfaces/product-data.ts +0 -83
- package/src/cms/pages.ts +0 -149
- package/src/pages/account/profile/profile-button.test.tsx +0 -59
- package/src/pages/account/profile/profile-button.tsx +0 -51
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { auth } from "@evenicanpm/storefront-core/src/lib/auth";
|
|
3
|
+
import { setTokenUtil } from "better-auth/oauth2";
|
|
4
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
5
|
+
|
|
6
|
+
type AuthSuccessResult = {
|
|
7
|
+
session: unknown;
|
|
8
|
+
user: {
|
|
9
|
+
id: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
emailVerified?: boolean;
|
|
13
|
+
image?: string | null;
|
|
14
|
+
};
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type LoginTokenPayload = {
|
|
19
|
+
email?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
given_name?: string;
|
|
22
|
+
oid?: string;
|
|
23
|
+
sub?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type SignupTokenPayload = LoginTokenPayload & {
|
|
27
|
+
preferred_username?: string;
|
|
28
|
+
emails?: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type TokenEmailPayload = {
|
|
32
|
+
email?: string;
|
|
33
|
+
preferred_username?: string;
|
|
34
|
+
emails?: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type SignupSessionOptions = {
|
|
38
|
+
firstName?: string;
|
|
39
|
+
lastName?: string;
|
|
40
|
+
accessToken?: string;
|
|
41
|
+
refreshToken?: string;
|
|
42
|
+
expiresInSeconds?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type AuthContext = Awaited<typeof auth.$context>;
|
|
46
|
+
|
|
47
|
+
function appendAuthHeaders(
|
|
48
|
+
response: NextResponse,
|
|
49
|
+
headers?: Record<string, string>,
|
|
50
|
+
): void {
|
|
51
|
+
if (!headers) return;
|
|
52
|
+
|
|
53
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
54
|
+
response.headers.append(key, value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createAuthResponse(authResult: AuthSuccessResult): NextResponse {
|
|
59
|
+
const response = NextResponse.json(
|
|
60
|
+
{
|
|
61
|
+
ok: true,
|
|
62
|
+
session: authResult.session,
|
|
63
|
+
user: authResult.user,
|
|
64
|
+
},
|
|
65
|
+
{ status: 200 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
appendAuthHeaders(response, authResult.headers);
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createSignedSessionToken(token: string, secret: string): string {
|
|
73
|
+
const signature = createHmac("sha256", secret).update(token).digest("base64");
|
|
74
|
+
return `${token}.${signature}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serializeCookie(
|
|
78
|
+
name: string,
|
|
79
|
+
value: string,
|
|
80
|
+
attributes: {
|
|
81
|
+
domain?: string;
|
|
82
|
+
httpOnly?: boolean;
|
|
83
|
+
maxAge?: number;
|
|
84
|
+
path?: string;
|
|
85
|
+
sameSite?: string;
|
|
86
|
+
secure?: boolean;
|
|
87
|
+
},
|
|
88
|
+
): string {
|
|
89
|
+
const parts = [`${name}=${value}`];
|
|
90
|
+
if (attributes.maxAge) parts.push(`Max-Age=${attributes.maxAge}`);
|
|
91
|
+
if (attributes.domain) parts.push(`Domain=${attributes.domain}`);
|
|
92
|
+
if (attributes.path) parts.push(`Path=${attributes.path}`);
|
|
93
|
+
if (attributes.httpOnly) parts.push("HttpOnly");
|
|
94
|
+
if (attributes.secure) parts.push("Secure");
|
|
95
|
+
if (attributes.sameSite) {
|
|
96
|
+
parts.push(
|
|
97
|
+
`SameSite=${attributes.sameSite[0]?.toUpperCase()}${attributes.sameSite.slice(1)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return parts.join("; ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getEntraAccountId(
|
|
104
|
+
payload: { oid?: string; sub?: string },
|
|
105
|
+
userId: string,
|
|
106
|
+
): string {
|
|
107
|
+
return String(payload.oid || payload.sub || userId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getTokenEmail(payload: TokenEmailPayload): string {
|
|
111
|
+
return (
|
|
112
|
+
payload.email ||
|
|
113
|
+
payload.preferred_username ||
|
|
114
|
+
(Array.isArray(payload.emails) ? payload.emails[0] : undefined) ||
|
|
115
|
+
""
|
|
116
|
+
)
|
|
117
|
+
.toString()
|
|
118
|
+
.trim()
|
|
119
|
+
.toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getLocalAuthPassword(email: string): string {
|
|
123
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
124
|
+
const secret = process.env.BETTER_AUTH_SECRET || "native-auth";
|
|
125
|
+
return `NativeAuth!${createHmac("sha256", secret).update(normalizedEmail).digest("hex")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveTokenExpiry(expiresInSeconds?: number): Date | undefined {
|
|
129
|
+
if (typeof expiresInSeconds !== "number" || expiresInSeconds <= 0) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return new Date(Date.now() + expiresInSeconds * 1000);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildSignupDisplayName(
|
|
137
|
+
firstName: string | undefined,
|
|
138
|
+
lastName: string | undefined,
|
|
139
|
+
payload: SignupTokenPayload,
|
|
140
|
+
fallbackEmail: string,
|
|
141
|
+
): string {
|
|
142
|
+
const normalizedFirstName =
|
|
143
|
+
typeof firstName === "string" ? firstName.trim() : "";
|
|
144
|
+
const normalizedLastName =
|
|
145
|
+
typeof lastName === "string" ? lastName.trim() : "";
|
|
146
|
+
const providedName = [normalizedFirstName, normalizedLastName]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(" ");
|
|
149
|
+
|
|
150
|
+
return providedName || payload.name || payload.given_name || fallbackEmail;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function ensureLocalCredentialAccount(
|
|
154
|
+
context: AuthContext,
|
|
155
|
+
email: string,
|
|
156
|
+
password: string,
|
|
157
|
+
name: string,
|
|
158
|
+
): Promise<{
|
|
159
|
+
id: string;
|
|
160
|
+
email?: string;
|
|
161
|
+
name?: string;
|
|
162
|
+
emailVerified?: boolean;
|
|
163
|
+
image?: string | null;
|
|
164
|
+
}> {
|
|
165
|
+
const existingUser = await context.internalAdapter.findUserByEmail(email, {
|
|
166
|
+
includeAccounts: true,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const passwordHash = await context.password.hash(password);
|
|
170
|
+
if (!existingUser?.user?.id) {
|
|
171
|
+
const createdUser = await context.internalAdapter.createUser({
|
|
172
|
+
email,
|
|
173
|
+
name,
|
|
174
|
+
emailVerified: true,
|
|
175
|
+
image: null,
|
|
176
|
+
});
|
|
177
|
+
await context.internalAdapter.linkAccount({
|
|
178
|
+
userId: createdUser.id,
|
|
179
|
+
providerId: "credential",
|
|
180
|
+
accountId: createdUser.id,
|
|
181
|
+
password: passwordHash,
|
|
182
|
+
});
|
|
183
|
+
return createdUser;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const credentialAccount = existingUser.accounts?.find(
|
|
187
|
+
(account: { providerId?: string; password?: string | null }) =>
|
|
188
|
+
account.providerId === "credential",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (credentialAccount) {
|
|
192
|
+
await context.internalAdapter.updatePassword(
|
|
193
|
+
existingUser.user.id,
|
|
194
|
+
passwordHash,
|
|
195
|
+
);
|
|
196
|
+
return existingUser.user;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await context.internalAdapter.linkAccount({
|
|
200
|
+
userId: existingUser.user.id,
|
|
201
|
+
providerId: "credential",
|
|
202
|
+
accountId: existingUser.user.id,
|
|
203
|
+
password: passwordHash,
|
|
204
|
+
});
|
|
205
|
+
return existingUser.user;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function createLocalAuthSession(
|
|
209
|
+
context: AuthContext,
|
|
210
|
+
user: {
|
|
211
|
+
id: string;
|
|
212
|
+
email?: string;
|
|
213
|
+
name?: string;
|
|
214
|
+
emailVerified?: boolean;
|
|
215
|
+
image?: string | null;
|
|
216
|
+
},
|
|
217
|
+
): Promise<AuthSuccessResult | null> {
|
|
218
|
+
const session = await context.internalAdapter.createSession(user.id);
|
|
219
|
+
if (!session) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const cookie = serializeCookie(
|
|
224
|
+
context.authCookies.sessionToken.name,
|
|
225
|
+
createSignedSessionToken(session.token, context.secret),
|
|
226
|
+
context.authCookies.sessionToken.attributes,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
session,
|
|
231
|
+
user,
|
|
232
|
+
headers: {
|
|
233
|
+
"set-cookie": cookie,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function signInOrCreateLocalUser(
|
|
239
|
+
_request: NextRequest,
|
|
240
|
+
email: string,
|
|
241
|
+
name: string,
|
|
242
|
+
): Promise<AuthSuccessResult | null> {
|
|
243
|
+
const localPassword = getLocalAuthPassword(email);
|
|
244
|
+
const context = await auth.$context;
|
|
245
|
+
const localUser = await ensureLocalCredentialAccount(
|
|
246
|
+
context,
|
|
247
|
+
email,
|
|
248
|
+
localPassword,
|
|
249
|
+
name,
|
|
250
|
+
);
|
|
251
|
+
return createLocalAuthSession(context, localUser);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function persistEntraAccountTokens(
|
|
255
|
+
userId: string,
|
|
256
|
+
entraAccountId: string,
|
|
257
|
+
idToken: string,
|
|
258
|
+
accessToken?: string,
|
|
259
|
+
refreshToken?: string,
|
|
260
|
+
expiresInSeconds?: number,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
try {
|
|
263
|
+
const tokenExpiry = resolveTokenExpiry(expiresInSeconds);
|
|
264
|
+
const ctx = await auth.$context;
|
|
265
|
+
const storedAccessToken = accessToken
|
|
266
|
+
? await setTokenUtil(accessToken, ctx)
|
|
267
|
+
: undefined;
|
|
268
|
+
const storedRefreshToken = refreshToken
|
|
269
|
+
? await setTokenUtil(refreshToken, ctx)
|
|
270
|
+
: undefined;
|
|
271
|
+
const existingAccounts = await ctx.internalAdapter.findAccounts(userId);
|
|
272
|
+
const existingEntraAccount = existingAccounts?.find(
|
|
273
|
+
(account: { id: string; providerId: string }) =>
|
|
274
|
+
account.providerId === "entra-external-id",
|
|
275
|
+
);
|
|
276
|
+
const tokenData = {
|
|
277
|
+
idToken,
|
|
278
|
+
...(storedAccessToken ? { accessToken: storedAccessToken } : {}),
|
|
279
|
+
...(storedRefreshToken ? { refreshToken: storedRefreshToken } : {}),
|
|
280
|
+
...(tokenExpiry ? { accessTokenExpiresAt: tokenExpiry } : {}),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (existingEntraAccount) {
|
|
284
|
+
await ctx.internalAdapter.updateAccount(
|
|
285
|
+
existingEntraAccount.id,
|
|
286
|
+
tokenData,
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await ctx.internalAdapter.createAccount({
|
|
292
|
+
userId,
|
|
293
|
+
providerId: "entra-external-id",
|
|
294
|
+
accountId: entraAccountId,
|
|
295
|
+
...tokenData,
|
|
296
|
+
});
|
|
297
|
+
} catch (tokenErr) {
|
|
298
|
+
console.warn(
|
|
299
|
+
"[native-session] Failed to upsert entra account tokens:",
|
|
300
|
+
tokenErr,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function decodeJwtPayload(token: string): Record<string, unknown> {
|
|
306
|
+
const parts = token.split(".");
|
|
307
|
+
if (parts.length !== 3) throw new Error("Invalid JWT format");
|
|
308
|
+
const payload = parts[1];
|
|
309
|
+
if (!payload) throw new Error("Invalid JWT payload");
|
|
310
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function createLoginSessionFromEntraTokens(
|
|
314
|
+
request: NextRequest,
|
|
315
|
+
idToken: string,
|
|
316
|
+
_password: string,
|
|
317
|
+
accessToken?: string,
|
|
318
|
+
refreshToken?: string,
|
|
319
|
+
expiresInSeconds?: number,
|
|
320
|
+
) {
|
|
321
|
+
try {
|
|
322
|
+
const payload = decodeJwtPayload(idToken) as LoginTokenPayload;
|
|
323
|
+
|
|
324
|
+
const email = getTokenEmail(payload);
|
|
325
|
+
if (!email) {
|
|
326
|
+
return NextResponse.json(
|
|
327
|
+
{ error: "missing_email", message: "Email claim missing from token" },
|
|
328
|
+
{ status: 400 },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const authResult = await signInOrCreateLocalUser(
|
|
333
|
+
request,
|
|
334
|
+
email,
|
|
335
|
+
payload.name || payload.given_name || email,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
if (!authResult) {
|
|
339
|
+
return NextResponse.json(
|
|
340
|
+
{
|
|
341
|
+
error: "login_failed",
|
|
342
|
+
message: "Unable to create or restore local session.",
|
|
343
|
+
},
|
|
344
|
+
{ status: 401 },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await persistEntraAccountTokens(
|
|
349
|
+
authResult.user.id,
|
|
350
|
+
getEntraAccountId(payload, authResult.user.id),
|
|
351
|
+
idToken,
|
|
352
|
+
accessToken,
|
|
353
|
+
refreshToken,
|
|
354
|
+
expiresInSeconds,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
return createAuthResponse(authResult);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
console.error("[native-session] Error creating login session:", error);
|
|
360
|
+
return NextResponse.json(
|
|
361
|
+
{ error: "failed_to_create_session", message: String(error) },
|
|
362
|
+
{ status: 500 },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function createSignupSessionFromEntraTokens(
|
|
368
|
+
_request: NextRequest,
|
|
369
|
+
assertedEmail: string,
|
|
370
|
+
_password: string,
|
|
371
|
+
idToken: string,
|
|
372
|
+
options: SignupSessionOptions = {},
|
|
373
|
+
) {
|
|
374
|
+
try {
|
|
375
|
+
const { firstName, lastName, accessToken, refreshToken, expiresInSeconds } =
|
|
376
|
+
options;
|
|
377
|
+
|
|
378
|
+
const payload = decodeJwtPayload(idToken) as SignupTokenPayload;
|
|
379
|
+
const tokenEmail = getTokenEmail(payload);
|
|
380
|
+
|
|
381
|
+
if (!tokenEmail) {
|
|
382
|
+
return NextResponse.json(
|
|
383
|
+
{ error: "missing_email", message: "Email claim missing from token" },
|
|
384
|
+
{ status: 400 },
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const normalizedAssertedEmail = assertedEmail.trim().toLowerCase();
|
|
389
|
+
if (normalizedAssertedEmail && normalizedAssertedEmail !== tokenEmail) {
|
|
390
|
+
return NextResponse.json(
|
|
391
|
+
{
|
|
392
|
+
error: "email_mismatch",
|
|
393
|
+
message: "Request email does not match token identity.",
|
|
394
|
+
},
|
|
395
|
+
{ status: 400 },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const context = await auth.$context;
|
|
400
|
+
const signUpResult = await createLocalAuthSession(
|
|
401
|
+
context,
|
|
402
|
+
await ensureLocalCredentialAccount(
|
|
403
|
+
context,
|
|
404
|
+
tokenEmail,
|
|
405
|
+
getLocalAuthPassword(tokenEmail),
|
|
406
|
+
buildSignupDisplayName(firstName, lastName, payload, tokenEmail),
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
if (!signUpResult) {
|
|
411
|
+
return NextResponse.json(
|
|
412
|
+
{ error: "signup_failed", message: "Failed to create user account" },
|
|
413
|
+
{ status: 400 },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await persistEntraAccountTokens(
|
|
418
|
+
signUpResult.user.id,
|
|
419
|
+
getEntraAccountId(payload, signUpResult.user.id),
|
|
420
|
+
idToken,
|
|
421
|
+
accessToken,
|
|
422
|
+
refreshToken,
|
|
423
|
+
expiresInSeconds,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return createAuthResponse(signUpResult);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
console.error("[native-session] Error creating signup session:", error);
|
|
429
|
+
return NextResponse.json(
|
|
430
|
+
{ error: "failed_to_create_session", message: String(error) },
|
|
431
|
+
{ status: 500 },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -11,6 +11,7 @@ import type { CountryRegionInfo } from "@msdyn365-commerce/retail-proxy";
|
|
|
11
11
|
import {
|
|
12
12
|
Box,
|
|
13
13
|
Button,
|
|
14
|
+
CircularProgress,
|
|
14
15
|
FormControl,
|
|
15
16
|
InputLabel,
|
|
16
17
|
MenuItem,
|
|
@@ -25,7 +26,7 @@ import { withZodSchema } from "formik-validator-zod";
|
|
|
25
26
|
import _ from "lodash";
|
|
26
27
|
import { useRouter } from "next/navigation";
|
|
27
28
|
import { useLocale, useTranslations } from "next-intl";
|
|
28
|
-
import React, { createContext, useContext } from "react";
|
|
29
|
+
import React, { createContext, useContext, useRef } from "react";
|
|
29
30
|
import { z } from "zod";
|
|
30
31
|
|
|
31
32
|
interface AddressFormValues {
|
|
@@ -71,6 +72,7 @@ const AddressForm = ({ address, children }: RootProps) => {
|
|
|
71
72
|
|
|
72
73
|
const { mutateAsync: createAddress } = useCreateAddress();
|
|
73
74
|
const { mutateAsync: updateAddress } = useUpdateAddress();
|
|
75
|
+
const isSubmittingRef = useRef(false);
|
|
74
76
|
|
|
75
77
|
const FormSchema = z.object({
|
|
76
78
|
Name: z.string().nonempty(t("Validation.required", { field: t("name") })),
|
|
@@ -99,6 +101,9 @@ const AddressForm = ({ address, children }: RootProps) => {
|
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
const handleSubmit = async (values: AddressFormValues) => {
|
|
104
|
+
if (isSubmittingRef.current) return;
|
|
105
|
+
isSubmittingRef.current = true;
|
|
106
|
+
|
|
102
107
|
let newAddress: Address = {
|
|
103
108
|
Name: values.Name,
|
|
104
109
|
Street: values.Street,
|
|
@@ -128,6 +133,7 @@ const AddressForm = ({ address, children }: RootProps) => {
|
|
|
128
133
|
severity: "error",
|
|
129
134
|
});
|
|
130
135
|
console.log(error);
|
|
136
|
+
isSubmittingRef.current = false;
|
|
131
137
|
}
|
|
132
138
|
};
|
|
133
139
|
|
|
@@ -291,8 +297,20 @@ AddressForm.Actions = ({ children }: { children?: React.ReactNode }) => {
|
|
|
291
297
|
|
|
292
298
|
AddressForm.SubmitButton = () => {
|
|
293
299
|
const t = useTranslations("Address");
|
|
300
|
+
const ctx = useContext(FormContext);
|
|
301
|
+
const isSubmitting = ctx?.formik.isSubmitting ?? false;
|
|
294
302
|
return (
|
|
295
|
-
<Button
|
|
303
|
+
<Button
|
|
304
|
+
type="submit"
|
|
305
|
+
variant="contained"
|
|
306
|
+
color="primary"
|
|
307
|
+
disabled={isSubmitting}
|
|
308
|
+
startIcon={
|
|
309
|
+
isSubmitting ? (
|
|
310
|
+
<CircularProgress size={20} color="inherit" />
|
|
311
|
+
) : undefined
|
|
312
|
+
}
|
|
313
|
+
>
|
|
296
314
|
{t("save")}
|
|
297
315
|
</Button>
|
|
298
316
|
);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { NextIntlClientProvider } from "next-intl";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import ProfileForm from "../profile-form";
|
|
5
|
+
|
|
6
|
+
const mockPush = vi.fn();
|
|
7
|
+
const mockInvalidateQueries = vi.fn();
|
|
8
|
+
const mockMutateAsync = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock("next/navigation", () => ({
|
|
11
|
+
useRouter: () => ({
|
|
12
|
+
push: mockPush,
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@tanstack/react-query", () => ({
|
|
17
|
+
useQueryClient: () => ({
|
|
18
|
+
invalidateQueries: (...args: unknown[]) => mockInvalidateQueries(...args),
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock(
|
|
23
|
+
"@evenicanpm/storefront-core/src/api-manager/services/user/mutations/update-user",
|
|
24
|
+
() => ({
|
|
25
|
+
default: () => ({
|
|
26
|
+
mutateAsync: (...args: unknown[]) => mockMutateAsync(...args),
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const user = {
|
|
32
|
+
AccountNumber: "CUST-001",
|
|
33
|
+
FirstName: "John",
|
|
34
|
+
LastName: "Doe",
|
|
35
|
+
Email: "john.doe@example.com",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const ProfileFormComponent = ProfileForm as unknown;
|
|
39
|
+
|
|
40
|
+
const messages = {
|
|
41
|
+
Account: {
|
|
42
|
+
Profile: {
|
|
43
|
+
fieldFirstName: "First Name",
|
|
44
|
+
fieldLastName: "Last Name",
|
|
45
|
+
validationRequired: "{field} is required",
|
|
46
|
+
validationNameInvalidCharacters: "Invalid characters",
|
|
47
|
+
validationNameTooLong: "Too long",
|
|
48
|
+
btnSaveProfile: "Save Profile",
|
|
49
|
+
errorUpdateFailed: "Update failed",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderForm = (props?: {
|
|
55
|
+
onBeforeSave?: (values: {
|
|
56
|
+
firstName: string;
|
|
57
|
+
lastName: string;
|
|
58
|
+
}) => Promise<void>;
|
|
59
|
+
onSuccess?: () => void;
|
|
60
|
+
}) =>
|
|
61
|
+
render(
|
|
62
|
+
<NextIntlClientProvider locale="en-US" messages={messages}>
|
|
63
|
+
<ProfileFormComponent
|
|
64
|
+
user={user}
|
|
65
|
+
onBeforeSave={props?.onBeforeSave}
|
|
66
|
+
onSuccess={props?.onSuccess}
|
|
67
|
+
>
|
|
68
|
+
<ProfileFormComponent.Fields>
|
|
69
|
+
<ProfileFormComponent.Field
|
|
70
|
+
name="firstName"
|
|
71
|
+
label="First Name"
|
|
72
|
+
autoComplete="given-name"
|
|
73
|
+
required
|
|
74
|
+
/>
|
|
75
|
+
<ProfileFormComponent.Field
|
|
76
|
+
name="lastName"
|
|
77
|
+
label="Last Name"
|
|
78
|
+
autoComplete="family-name"
|
|
79
|
+
required
|
|
80
|
+
/>
|
|
81
|
+
</ProfileFormComponent.Fields>
|
|
82
|
+
<ProfileFormComponent.Error />
|
|
83
|
+
<ProfileFormComponent.Actions>
|
|
84
|
+
<ProfileFormComponent.SubmitButton />
|
|
85
|
+
</ProfileFormComponent.Actions>
|
|
86
|
+
</ProfileFormComponent>
|
|
87
|
+
</NextIntlClientProvider>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
describe("ProfileForm", () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.restoreAllMocks();
|
|
93
|
+
mockPush.mockReset();
|
|
94
|
+
mockInvalidateQueries.mockReset();
|
|
95
|
+
mockMutateAsync.mockReset();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders initial values from user", () => {
|
|
99
|
+
renderForm();
|
|
100
|
+
|
|
101
|
+
expect(screen.getByLabelText(/First Name/i)).toHaveProperty(
|
|
102
|
+
"value",
|
|
103
|
+
"John",
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByLabelText(/Last Name/i)).toHaveProperty("value", "Doe");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not submit when required fields are invalid", async () => {
|
|
109
|
+
renderForm();
|
|
110
|
+
|
|
111
|
+
fireEvent.change(screen.getByLabelText(/First Name/i), {
|
|
112
|
+
target: { value: " " },
|
|
113
|
+
});
|
|
114
|
+
fireEvent.click(screen.getByRole("button", { name: "Save Profile" }));
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(mockMutateAsync).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("calls onBeforeSave, updates user, invalidates query, and routes on success", async () => {
|
|
123
|
+
const onBeforeSave = vi.fn(async () => undefined);
|
|
124
|
+
mockMutateAsync.mockResolvedValue({});
|
|
125
|
+
renderForm({ onBeforeSave });
|
|
126
|
+
|
|
127
|
+
fireEvent.change(screen.getByLabelText(/First Name/i), {
|
|
128
|
+
target: { value: " Jane " },
|
|
129
|
+
});
|
|
130
|
+
fireEvent.change(screen.getByLabelText(/Last Name/i), {
|
|
131
|
+
target: { value: " Smith " },
|
|
132
|
+
});
|
|
133
|
+
fireEvent.click(screen.getByRole("button", { name: "Save Profile" }));
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(onBeforeSave).toHaveBeenCalledWith({
|
|
137
|
+
firstName: "Jane",
|
|
138
|
+
lastName: "Smith",
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
expect(mockMutateAsync).toHaveBeenCalledWith({
|
|
142
|
+
AccountNumber: "",
|
|
143
|
+
Email: "john.doe@example.com",
|
|
144
|
+
FirstName: "Jane",
|
|
145
|
+
LastName: "Smith",
|
|
146
|
+
});
|
|
147
|
+
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ["me"] });
|
|
148
|
+
expect(mockPush).toHaveBeenCalledWith("/account/profile");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("uses onSuccess callback instead of router push when provided", async () => {
|
|
152
|
+
const onSuccess = vi.fn();
|
|
153
|
+
mockMutateAsync.mockResolvedValue({});
|
|
154
|
+
renderForm({ onSuccess });
|
|
155
|
+
|
|
156
|
+
fireEvent.click(screen.getByRole("button", { name: "Save Profile" }));
|
|
157
|
+
|
|
158
|
+
await waitFor(() => {
|
|
159
|
+
expect(onSuccess).toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renders submit error message when update fails", async () => {
|
|
165
|
+
mockMutateAsync.mockRejectedValue(new Error("save failed"));
|
|
166
|
+
renderForm();
|
|
167
|
+
|
|
168
|
+
fireEvent.click(screen.getByRole("button", { name: "Save Profile" }));
|
|
169
|
+
|
|
170
|
+
expect(await screen.findByText("save failed")).toBeTruthy();
|
|
171
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
});
|