@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/package.json +15 -2
  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/product-section-fullwidth/index.tsx +3 -1
  11. package/src/components/categories/category-list/category-list.tsx +9 -5
  12. package/src/components/header/__tests__/user.test.tsx +5 -107
  13. package/src/components/header/components/user.tsx +72 -45
  14. package/src/hooks/use-nextauth-session.ts +0 -33
  15. package/src/lib/auth-client.ts +14 -0
  16. package/src/lib/auth.ts +4 -0
  17. package/src/lib/entra-native-auth.ts +138 -0
  18. package/src/lib/native-session.ts +434 -0
  19. package/src/pages/account/profile/__tests__/profile-form.test.tsx +173 -0
  20. package/src/pages/account/profile/profile-form.tsx +285 -0
  21. package/src/pages/account/profile/profile-validation.ts +52 -0
  22. package/src/pages/auth/auth-activate-page.tsx +509 -0
  23. package/src/pages/auth/auth-layout.tsx +73 -0
  24. package/src/pages/auth/auth-login-page.tsx +241 -0
  25. package/src/pages/auth/auth-reset-password-page.tsx +487 -0
  26. package/src/pages/auth/compound/auth-form.tsx +182 -0
  27. package/src/pages/auth/compound/auth-pages.tsx +21 -0
  28. package/src/pages/auth/lib/friendly-error.ts +70 -0
  29. package/src/pages/auth/lib/index.ts +2 -0
  30. package/src/pages/auth/lib/types.ts +5 -0
  31. package/src/pages/checkout/checkout-alt-form/steps/address/delivery-address.tsx +20 -6
  32. package/src/pages/checkout/checkout-alt-form/steps/customer-info/customer-information.tsx +1 -1
  33. package/tsconfig.json +20 -14
  34. package/src/auth/msal.ts +0 -65
  35. package/src/pages/account/profile/profile-button.test.tsx +0 -59
  36. package/src/pages/account/profile/profile-button.tsx +0 -51
@@ -0,0 +1,509 @@
1
+ "use client";
2
+
3
+ import AuthForm from "@evenicanpm/storefront-core/src/pages/auth/compound/auth-form";
4
+ import type { OtpMeta } from "@evenicanpm/storefront-core/src/pages/auth/lib";
5
+ import { friendlyError } from "@evenicanpm/storefront-core/src/pages/auth/lib";
6
+ import {
7
+ Box,
8
+ Button,
9
+ CircularProgress,
10
+ Divider,
11
+ TextField,
12
+ Typography,
13
+ } from "@mui/material";
14
+ import Link from "next/link";
15
+ import { useRouter, useSearchParams } from "next/navigation";
16
+ import { useTranslations } from "next-intl";
17
+ import {
18
+ type ChangeEvent,
19
+ createContext,
20
+ type FormEvent,
21
+ type ReactNode,
22
+ useContext,
23
+ useMemo,
24
+ useState,
25
+ } from "react";
26
+
27
+ type Step = "details" | "otp" | "activating";
28
+
29
+ type ActivateContextValue = {
30
+ t: ReturnType<typeof useTranslations>;
31
+ tCommon: ReturnType<typeof useTranslations>;
32
+ tErrors: ReturnType<typeof useTranslations>;
33
+ step: Step;
34
+ error: string | null;
35
+ email: string;
36
+ firstName: string;
37
+ lastName: string;
38
+ password: string;
39
+ confirmPassword: string;
40
+ detailsLoading: boolean;
41
+ otp: string;
42
+ otpMeta: OtpMeta | null;
43
+ otpLoading: boolean;
44
+ passwordMismatch: boolean;
45
+ setStep: (step: Step) => void;
46
+ setError: (message: string | null) => void;
47
+ setEmail: (value: string) => void;
48
+ setFirstName: (value: string) => void;
49
+ setLastName: (value: string) => void;
50
+ setPassword: (value: string) => void;
51
+ setConfirmPassword: (value: string) => void;
52
+ setOtp: (value: string) => void;
53
+ handleStart: (event: FormEvent<HTMLFormElement>) => Promise<void>;
54
+ handleOtpSubmit: (event: FormEvent<HTMLFormElement>) => Promise<void>;
55
+ };
56
+
57
+ const AuthActivatePageContext = createContext<ActivateContextValue | null>(
58
+ null,
59
+ );
60
+
61
+ function getStringValue(value: unknown): string {
62
+ return typeof value === "string" ? value : "";
63
+ }
64
+
65
+ const useAuthActivatePage = () => {
66
+ const context = useContext(AuthActivatePageContext);
67
+ if (!context) {
68
+ throw new Error(
69
+ "AuthActivatePage compound components must be used within AuthActivatePage",
70
+ );
71
+ }
72
+ return context;
73
+ };
74
+
75
+ function AuthActivateHeader() {
76
+ const { t, tCommon, step, otpMeta } = useAuthActivatePage();
77
+
78
+ if (step === "activating") {
79
+ return null;
80
+ }
81
+
82
+ return (
83
+ <>
84
+ <Typography variant="h5" fontWeight={700} gutterBottom>
85
+ {t("title")}
86
+ </Typography>
87
+ <Typography variant="body2" color="text.secondary" mb={3}>
88
+ {step === "details"
89
+ ? t("subtitleDetails")
90
+ : t("subtitleOtp", {
91
+ codeLength: otpMeta?.code_length ?? 8,
92
+ target: otpMeta?.challenge_target_label || tCommon("yourEmail"),
93
+ })}
94
+ </Typography>
95
+ <Divider sx={{ mb: 3 }} />
96
+ </>
97
+ );
98
+ }
99
+
100
+ function AuthActivateError() {
101
+ const { error, step } = useAuthActivatePage();
102
+
103
+ if (step === "activating") {
104
+ return null;
105
+ }
106
+
107
+ return <AuthForm.Error message={error} />;
108
+ }
109
+
110
+ function AuthActivateDetailsForm() {
111
+ const {
112
+ t,
113
+ tCommon,
114
+ step,
115
+ firstName,
116
+ lastName,
117
+ email,
118
+ password,
119
+ confirmPassword,
120
+ detailsLoading,
121
+ passwordMismatch,
122
+ setFirstName,
123
+ setLastName,
124
+ setEmail,
125
+ setPassword,
126
+ setConfirmPassword,
127
+ handleStart,
128
+ } = useAuthActivatePage();
129
+
130
+ if (step !== "details") {
131
+ return null;
132
+ }
133
+
134
+ return (
135
+ <AuthForm onSubmit={handleStart}>
136
+ <TextField
137
+ label={t("firstNameLabel")}
138
+ value={firstName}
139
+ onChange={(
140
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
141
+ ) => setFirstName(event.target.value)}
142
+ fullWidth
143
+ required
144
+ autoComplete="given-name"
145
+ autoFocus
146
+ sx={{ mb: 2 }}
147
+ disabled={detailsLoading}
148
+ />
149
+ <TextField
150
+ label={t("lastNameLabel")}
151
+ value={lastName}
152
+ onChange={(
153
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
154
+ ) => setLastName(event.target.value)}
155
+ fullWidth
156
+ required
157
+ autoComplete="family-name"
158
+ sx={{ mb: 2 }}
159
+ disabled={detailsLoading}
160
+ />
161
+ <TextField
162
+ label={t("emailLabel")}
163
+ type="email"
164
+ value={email}
165
+ onChange={(
166
+ event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
167
+ ) => setEmail(event.target.value)}
168
+ fullWidth
169
+ required
170
+ autoComplete="email"
171
+ sx={{ mb: 2 }}
172
+ disabled={detailsLoading}
173
+ />
174
+ <AuthForm.PasswordFields
175
+ password={password}
176
+ confirmPassword={confirmPassword}
177
+ onPasswordChange={setPassword}
178
+ onConfirmChange={setConfirmPassword}
179
+ disabled={detailsLoading}
180
+ autoFocus={false}
181
+ label={tCommon("passwordLabel")}
182
+ confirmLabel={tCommon("confirmPasswordLabel")}
183
+ mismatchHelperText={tCommon("passwordsDoNotMatch")}
184
+ />
185
+ <AuthForm.SubmitButton
186
+ label={t("continue")}
187
+ loading={detailsLoading}
188
+ disabled={
189
+ !firstName.trim() ||
190
+ !lastName.trim() ||
191
+ !email ||
192
+ !password ||
193
+ !confirmPassword ||
194
+ passwordMismatch
195
+ }
196
+ />
197
+ </AuthForm>
198
+ );
199
+ }
200
+
201
+ function AuthActivateOtpForm() {
202
+ const {
203
+ t,
204
+ step,
205
+ otp,
206
+ otpMeta,
207
+ otpLoading,
208
+ setStep,
209
+ setOtp,
210
+ setError,
211
+ handleOtpSubmit,
212
+ } = useAuthActivatePage();
213
+
214
+ if (step !== "otp" || !otpMeta) {
215
+ return null;
216
+ }
217
+
218
+ return (
219
+ <AuthForm onSubmit={handleOtpSubmit}>
220
+ <AuthForm.OtpField
221
+ value={otp}
222
+ onChange={setOtp}
223
+ codeLength={otpMeta.code_length}
224
+ disabled={otpLoading}
225
+ label={t("verificationCodeLabel")}
226
+ />
227
+ <AuthForm.SubmitButton
228
+ label={t("verifyAndActivate")}
229
+ loading={otpLoading}
230
+ disabled={otp.length < otpMeta.code_length}
231
+ />
232
+ <Button
233
+ variant="text"
234
+ color="secondary"
235
+ fullWidth
236
+ sx={{ mt: 1 }}
237
+ onClick={() => {
238
+ setStep("details");
239
+ setOtp("");
240
+ setError(null);
241
+ }}
242
+ >
243
+ {t("back")}
244
+ </Button>
245
+ </AuthForm>
246
+ );
247
+ }
248
+
249
+ function AuthActivateActivatingState() {
250
+ const { t } = useAuthActivatePage();
251
+
252
+ return (
253
+ <Box
254
+ sx={{
255
+ display: "flex",
256
+ minHeight: "100vh",
257
+ alignItems: "center",
258
+ justifyContent: "center",
259
+ }}
260
+ >
261
+ <Box sx={{ textAlign: "center" }}>
262
+ <CircularProgress sx={{ mb: 2 }} />
263
+ <Typography variant="body1" color="text.secondary">
264
+ {t("activating")}
265
+ </Typography>
266
+ </Box>
267
+ </Box>
268
+ );
269
+ }
270
+
271
+ function AuthActivateFooter() {
272
+ const { t, step } = useAuthActivatePage();
273
+
274
+ if (step === "activating") {
275
+ return null;
276
+ }
277
+
278
+ return (
279
+ <Box sx={{ mt: 3, textAlign: "center" }}>
280
+ <Link href="/login" style={{ fontSize: "0.875rem" }}>
281
+ {t("backToSignIn")}
282
+ </Link>
283
+ </Box>
284
+ );
285
+ }
286
+
287
+ function AuthActivateDefaultView() {
288
+ return (
289
+ <>
290
+ <AuthActivatePage.Activating />
291
+ <AuthActivatePage.Header />
292
+ <AuthActivatePage.Error />
293
+ <AuthActivatePage.DetailsForm />
294
+ <AuthActivatePage.OtpForm />
295
+ <AuthActivatePage.Footer />
296
+ </>
297
+ );
298
+ }
299
+
300
+ function AuthActivatePageRoot({
301
+ children,
302
+ }: Readonly<{ children?: ReactNode }>) {
303
+ const t = useTranslations("Auth.Activate");
304
+ const tCommon = useTranslations("Auth.Common");
305
+ const tErrors = useTranslations("Auth.Errors");
306
+
307
+ const router = useRouter();
308
+ const searchParams = useSearchParams();
309
+ const callbackUrl =
310
+ searchParams.get("callbackUrl") ?? searchParams.get("redirect") ?? "/";
311
+
312
+ const [step, setStep] = useState<Step>("details");
313
+ const [error, setError] = useState<string | null>(null);
314
+
315
+ const [email, setEmail] = useState("");
316
+ const [firstName, setFirstName] = useState("");
317
+ const [lastName, setLastName] = useState("");
318
+ const [password, setPassword] = useState("");
319
+ const [confirmPassword, setConfirmPassword] = useState("");
320
+ const [detailsLoading, setDetailsLoading] = useState(false);
321
+
322
+ const [otp, setOtp] = useState("");
323
+ const [otpMeta, setOtpMeta] = useState<OtpMeta | null>(null);
324
+ const [otpLoading, setOtpLoading] = useState(false);
325
+
326
+ const passwordMismatch =
327
+ confirmPassword.length > 0 && password !== confirmPassword;
328
+
329
+ const handleStart = async (event: FormEvent<HTMLFormElement>) => {
330
+ event.preventDefault();
331
+ if (password.length < 8) {
332
+ setError(tErrors("passwordMinLength"));
333
+ return;
334
+ }
335
+ if (password !== confirmPassword) {
336
+ setError(tCommon("passwordsDoNotMatch"));
337
+ return;
338
+ }
339
+
340
+ setError(null);
341
+ setDetailsLoading(true);
342
+ try {
343
+ const response = await fetch("/api/activate", {
344
+ method: "POST",
345
+ headers: { "Content-Type": "application/json" },
346
+ body: JSON.stringify({
347
+ email,
348
+ password,
349
+ firstName: firstName.trim(),
350
+ lastName: lastName.trim(),
351
+ }),
352
+ });
353
+ const data = (await response.json()) as Record<string, unknown>;
354
+ if (!response.ok) {
355
+ setError(
356
+ friendlyError(
357
+ getStringValue(data.error),
358
+ getStringValue(data.suberror),
359
+ tErrors,
360
+ ),
361
+ );
362
+ return;
363
+ }
364
+
365
+ setOtpMeta({
366
+ continuation_token: getStringValue(data.continuation_token),
367
+ challenge_target_label:
368
+ getStringValue(data.challenge_target_label) || email,
369
+ code_length: Number(data.code_length || 8),
370
+ });
371
+ setStep("otp");
372
+ } catch {
373
+ setError(tErrors("unableToStartActivation"));
374
+ } finally {
375
+ setDetailsLoading(false);
376
+ }
377
+ };
378
+
379
+ const handleOtpSubmit = async (event: FormEvent<HTMLFormElement>) => {
380
+ event.preventDefault();
381
+ if (!otpMeta) return;
382
+ setError(null);
383
+ setOtpLoading(true);
384
+ setStep("activating");
385
+
386
+ try {
387
+ const response = await fetch("/api/activate/verify", {
388
+ method: "POST",
389
+ headers: { "Content-Type": "application/json" },
390
+ body: JSON.stringify({
391
+ email,
392
+ password,
393
+ firstName: firstName.trim(),
394
+ lastName: lastName.trim(),
395
+ otp,
396
+ continuation_token: otpMeta.continuation_token,
397
+ }),
398
+ });
399
+
400
+ if (!response.ok) {
401
+ const data = (await response.json().catch(() => null)) as {
402
+ error?: string;
403
+ suberror?: string;
404
+ } | null;
405
+ setError(
406
+ friendlyError(
407
+ getStringValue(data?.error),
408
+ getStringValue(data?.suberror),
409
+ tErrors,
410
+ ),
411
+ );
412
+ setStep("otp");
413
+ return;
414
+ }
415
+
416
+ router.push(callbackUrl);
417
+ } catch {
418
+ setError(tErrors("activationFailed"));
419
+ setStep("otp");
420
+ } finally {
421
+ setOtpLoading(false);
422
+ }
423
+ };
424
+
425
+ const contextValue = useMemo(
426
+ () => ({
427
+ t,
428
+ tCommon,
429
+ tErrors,
430
+ step,
431
+ error,
432
+ email,
433
+ firstName,
434
+ lastName,
435
+ password,
436
+ confirmPassword,
437
+ detailsLoading,
438
+ otp,
439
+ otpMeta,
440
+ otpLoading,
441
+ passwordMismatch,
442
+ setStep,
443
+ setError,
444
+ setEmail,
445
+ setFirstName,
446
+ setLastName,
447
+ setPassword,
448
+ setConfirmPassword,
449
+ setOtp,
450
+ handleStart,
451
+ handleOtpSubmit,
452
+ }),
453
+ [
454
+ t,
455
+ tCommon,
456
+ tErrors,
457
+ step,
458
+ error,
459
+ email,
460
+ firstName,
461
+ lastName,
462
+ password,
463
+ confirmPassword,
464
+ detailsLoading,
465
+ otp,
466
+ otpMeta,
467
+ otpLoading,
468
+ passwordMismatch,
469
+ setStep,
470
+ setError,
471
+ setEmail,
472
+ setFirstName,
473
+ setLastName,
474
+ setPassword,
475
+ setConfirmPassword,
476
+ setOtp,
477
+ handleStart,
478
+ handleOtpSubmit,
479
+ ],
480
+ );
481
+
482
+ return (
483
+ <AuthActivatePageContext.Provider value={contextValue}>
484
+ {children ?? <AuthActivatePage.Default />}
485
+ </AuthActivatePageContext.Provider>
486
+ );
487
+ }
488
+
489
+ type AuthActivatePageComponent = typeof AuthActivatePageRoot & {
490
+ Header: typeof AuthActivateHeader;
491
+ Error: typeof AuthActivateError;
492
+ DetailsForm: typeof AuthActivateDetailsForm;
493
+ OtpForm: typeof AuthActivateOtpForm;
494
+ Activating: typeof AuthActivateActivatingState;
495
+ Footer: typeof AuthActivateFooter;
496
+ Default: typeof AuthActivateDefaultView;
497
+ };
498
+
499
+ const AuthActivatePage = AuthActivatePageRoot as AuthActivatePageComponent;
500
+
501
+ AuthActivatePage.Header = AuthActivateHeader;
502
+ AuthActivatePage.Error = AuthActivateError;
503
+ AuthActivatePage.DetailsForm = AuthActivateDetailsForm;
504
+ AuthActivatePage.OtpForm = AuthActivateOtpForm;
505
+ AuthActivatePage.Activating = AuthActivateActivatingState;
506
+ AuthActivatePage.Footer = AuthActivateFooter;
507
+ AuthActivatePage.Default = AuthActivateDefaultView;
508
+
509
+ export default AuthActivatePage;
@@ -0,0 +1,73 @@
1
+ import ArrowBackIcon from "@mui/icons-material/ArrowBack";
2
+ import { Box, IconButton } from "@mui/material";
3
+ import Image from "next/image";
4
+ import Link from "next/link";
5
+ import { getTranslations } from "next-intl/server";
6
+ import type { ReactNode } from "react";
7
+
8
+ export default async function AuthLayout({
9
+ children,
10
+ }: Readonly<{
11
+ children: ReactNode;
12
+ }>) {
13
+ const t = await getTranslations("Auth.Layout");
14
+
15
+ return (
16
+ <Box sx={{ display: "flex", minHeight: "100vh" }}>
17
+ <Box
18
+ sx={{
19
+ display: { xs: "none", md: "block" },
20
+ flex: 1,
21
+ position: "relative",
22
+ overflow: "hidden",
23
+ bgcolor: "action.hover",
24
+ }}
25
+ >
26
+ <Image
27
+ src="/e-Icon.svg"
28
+ alt={t("iconAlt")}
29
+ fill
30
+ style={{ objectFit: "contain", padding: "8%" }}
31
+ priority
32
+ />
33
+ <IconButton
34
+ component={Link}
35
+ href="/"
36
+ color="secondary"
37
+ sx={{
38
+ position: "absolute",
39
+ top: 16,
40
+ left: 16,
41
+ bgcolor: "background.paper",
42
+ "&:hover": { bgcolor: "action.selected" },
43
+ }}
44
+ aria-label={t("backToHome")}
45
+ >
46
+ <ArrowBackIcon />
47
+ </IconButton>
48
+ </Box>
49
+ <Box
50
+ sx={{
51
+ width: { xs: "100%", md: 480 },
52
+ px: { xs: 3, sm: 6 },
53
+ py: 6,
54
+ bgcolor: "background.paper",
55
+ display: "flex",
56
+ flexDirection: "column",
57
+ justifyContent: "center",
58
+ }}
59
+ >
60
+ <Box sx={{ display: "flex", justifyContent: "center", mb: 4 }}>
61
+ <Image
62
+ src="/logo-blue.png"
63
+ alt={t("logoAlt")}
64
+ width={172}
65
+ height={52}
66
+ priority
67
+ />
68
+ </Box>
69
+ {children}
70
+ </Box>
71
+ </Box>
72
+ );
73
+ }