@pradip1995/segment-login-template 0.5.12 → 0.5.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -7
- package/src/account-orders.tsx +7 -4
- package/src/account-profile-logic.ts +80 -12
- package/src/account-profile.tsx +12 -26
- package/src/auth-server.ts +76 -1
- package/src/profile-email-field.tsx +116 -0
- package/src/profile-email.test.ts +182 -0
- package/src/profile-email.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pradip1995/segment-login-template",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,9 +19,15 @@
|
|
|
19
19
|
"./auth-server": "./src/auth-server.ts",
|
|
20
20
|
"./account-profile-logic": "./src/account-profile-logic.ts",
|
|
21
21
|
"./profile-phone-field": "./src/profile-phone-field.tsx",
|
|
22
|
+
"./profile-email-field": "./src/profile-email-field.tsx",
|
|
22
23
|
"./google-auth-section": "./src/google-auth-section.tsx",
|
|
23
24
|
"./otp-input": "./src/otp-input.tsx"
|
|
24
25
|
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"lint": "tsc --noEmit",
|
|
29
|
+
"test": "node --experimental-strip-types --test src/*.test.ts"
|
|
30
|
+
},
|
|
25
31
|
"peerDependencies": {
|
|
26
32
|
"@pradip1995/commerce-auth": "^4.0.0",
|
|
27
33
|
"@pradip1995/commerce-core": "^4.0.0",
|
|
@@ -33,7 +39,7 @@
|
|
|
33
39
|
"dependencies": {
|
|
34
40
|
"@pradip1995/commerce-auth": "^4.0.0",
|
|
35
41
|
"@pradip1995/commerce-core": "^4.0.0",
|
|
36
|
-
"@pradip1995/segment-primitives": "^0.4.
|
|
42
|
+
"@pradip1995/segment-primitives": "^0.4.11",
|
|
37
43
|
"@pradip1995/segment-tokens": "^0.3.7"
|
|
38
44
|
},
|
|
39
45
|
"devDependencies": {
|
|
@@ -41,9 +47,5 @@
|
|
|
41
47
|
"@types/react": "^19",
|
|
42
48
|
"react": "19.0.3",
|
|
43
49
|
"typescript": "^5.7.2"
|
|
44
|
-
},
|
|
45
|
-
"scripts": {
|
|
46
|
-
"typecheck": "tsc --noEmit",
|
|
47
|
-
"lint": "tsc --noEmit"
|
|
48
50
|
}
|
|
49
|
-
}
|
|
51
|
+
}
|
package/src/account-orders.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import type { HttpTypes } from "@medusajs/types"
|
|
|
6
6
|
import { addToCart } from "@pradip1995/commerce-core/client/actions/cart"
|
|
7
7
|
import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
|
|
8
8
|
import { formatPrice } from "@pradip1995/segment-primitives/format-price"
|
|
9
|
+
import { presentOrderLines } from "@pradip1995/segment-primitives/order-lines"
|
|
9
10
|
import { acct } from "./account-theme"
|
|
10
11
|
|
|
11
12
|
function isCancelled(order: HttpTypes.StoreOrder) {
|
|
@@ -76,7 +77,8 @@ export default function AccountOrders({
|
|
|
76
77
|
if (!canReorder(order) || reorderingId) return
|
|
77
78
|
setReorderingId(order.id)
|
|
78
79
|
try {
|
|
79
|
-
|
|
80
|
+
const reorderItems = presentOrderLines(order).reorderItems
|
|
81
|
+
for (const item of reorderItems) {
|
|
80
82
|
if (!item.variant_id) continue
|
|
81
83
|
await addToCart({
|
|
82
84
|
variantId: item.variant_id,
|
|
@@ -125,6 +127,7 @@ export default function AccountOrders({
|
|
|
125
127
|
{filtered.map((order) => {
|
|
126
128
|
const label = statusLabel(order)
|
|
127
129
|
const showReorder = canReorder(order)
|
|
130
|
+
const presentation = presentOrderLines(order)
|
|
128
131
|
return (
|
|
129
132
|
<li key={order.id}>
|
|
130
133
|
<div
|
|
@@ -148,11 +151,11 @@ export default function AccountOrders({
|
|
|
148
151
|
dateStyle: "medium",
|
|
149
152
|
})}
|
|
150
153
|
{" · "}
|
|
151
|
-
{
|
|
152
|
-
{
|
|
154
|
+
{presentation.purchasedItems.length} item
|
|
155
|
+
{presentation.purchasedItems.length === 1 ? "" : "s"}
|
|
153
156
|
</p>
|
|
154
157
|
<p className={`text-base font-bold ${acct.accent}`}>
|
|
155
|
-
{formatPrice(
|
|
158
|
+
{formatPrice(presentation.paidTotal, order.currency_code)}
|
|
156
159
|
</p>
|
|
157
160
|
</LocalizedLink>
|
|
158
161
|
{showReorder ? (
|
|
@@ -9,10 +9,19 @@ import {
|
|
|
9
9
|
parsePhoneParts,
|
|
10
10
|
} from "@pradip1995/commerce-core/util/phone"
|
|
11
11
|
import {
|
|
12
|
+
requestContactChange,
|
|
12
13
|
sendRegistrationOtp,
|
|
14
|
+
verifyContactChange,
|
|
13
15
|
verifyRegistrationOtp,
|
|
14
16
|
} from "./auth-server"
|
|
15
17
|
import { isNextRedirect, safeErrorMessage } from "./is-next-redirect"
|
|
18
|
+
import {
|
|
19
|
+
EMAIL_OTP_ERRORS,
|
|
20
|
+
isValidEmail,
|
|
21
|
+
normalizeEmail,
|
|
22
|
+
resolveEmailOtpFlow,
|
|
23
|
+
type EmailOtpFlow,
|
|
24
|
+
} from "./profile-email"
|
|
16
25
|
|
|
17
26
|
export function customerPhoneToInputValue(phone?: string | null): string {
|
|
18
27
|
if (!phone?.trim()) return ""
|
|
@@ -50,21 +59,27 @@ export function useAccountProfileLogic(
|
|
|
50
59
|
const [message, setMessage] = useState<string | null>(null)
|
|
51
60
|
const [phone, setPhone] = useState(() => customerPhoneToInputValue(customer.phone))
|
|
52
61
|
const [phoneError, setPhoneError] = useState<string | null>(null)
|
|
62
|
+
const [email, setEmail] = useState(() => customer.email || "")
|
|
63
|
+
const [emailError, setEmailError] = useState<string | null>(null)
|
|
53
64
|
const [otpOpen, setOtpOpen] = useState(false)
|
|
54
65
|
const [otp, setOtp] = useState("")
|
|
55
66
|
const [otpToken, setOtpToken] = useState<string | null>(null)
|
|
56
67
|
const [otpType, setOtpType] = useState<"email_verification" | "phone_verification">(
|
|
57
68
|
"email_verification"
|
|
58
69
|
)
|
|
70
|
+
const [otpFlow, setOtpFlow] = useState<EmailOtpFlow>("verify_existing")
|
|
59
71
|
const [otpError, setOtpError] = useState<string | null>(null)
|
|
60
72
|
const [otpPending, setOtpPending] = useState(false)
|
|
61
73
|
const [phoneVerified, setPhoneVerified] = useState(() =>
|
|
62
74
|
readVerified(customer, "phone_verified")
|
|
63
75
|
)
|
|
64
|
-
const [emailVerified] = useState(() =>
|
|
76
|
+
const [emailVerified, setEmailVerified] = useState(() =>
|
|
77
|
+
readVerified(customer, "email_verified")
|
|
78
|
+
)
|
|
65
79
|
|
|
66
80
|
const phoneParts = parsePhoneParts(phone)
|
|
67
81
|
const phoneReady = isValidPhoneParts(phoneParts.dialCode, phoneParts.localNumber)
|
|
82
|
+
const emailReady = isValidEmail(email)
|
|
68
83
|
|
|
69
84
|
async function saveProfile(_prev: unknown, formData: FormData) {
|
|
70
85
|
try {
|
|
@@ -127,6 +142,30 @@ export function useAccountProfileLogic(
|
|
|
127
142
|
if (type === "phone_verification") {
|
|
128
143
|
const saved = await ensurePhoneSavedForVerification()
|
|
129
144
|
if (!saved) return
|
|
145
|
+
setOtpFlow("verify_existing")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (type === "email_verification") {
|
|
149
|
+
const resolved = resolveEmailOtpFlow({
|
|
150
|
+
currentEmail: customer.email,
|
|
151
|
+
draftEmail: email,
|
|
152
|
+
})
|
|
153
|
+
if (!resolved.ok) {
|
|
154
|
+
setEmailError(EMAIL_OTP_ERRORS[resolved.reason])
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
setOtpFlow(resolved.flow)
|
|
158
|
+
const res =
|
|
159
|
+
resolved.flow === "change_contact"
|
|
160
|
+
? await requestContactChange({ email: resolved.email })
|
|
161
|
+
: await sendRegistrationOtp(customer.id, type)
|
|
162
|
+
if (!res?.success) {
|
|
163
|
+
throw new Error(res?.error || "Failed to send code")
|
|
164
|
+
}
|
|
165
|
+
setOtpToken(res.token ?? null)
|
|
166
|
+
setOtp("")
|
|
167
|
+
setOtpOpen(true)
|
|
168
|
+
return
|
|
130
169
|
}
|
|
131
170
|
|
|
132
171
|
const res = await sendRegistrationOtp(customer.id, type)
|
|
@@ -143,6 +182,9 @@ export function useAccountProfileLogic(
|
|
|
143
182
|
if (type === "phone_verification") {
|
|
144
183
|
setPhoneError(msg)
|
|
145
184
|
}
|
|
185
|
+
if (type === "email_verification") {
|
|
186
|
+
setEmailError(msg)
|
|
187
|
+
}
|
|
146
188
|
} finally {
|
|
147
189
|
setOtpPending(false)
|
|
148
190
|
}
|
|
@@ -153,19 +195,33 @@ export function useAccountProfileLogic(
|
|
|
153
195
|
setOtpPending(true)
|
|
154
196
|
setOtpError(null)
|
|
155
197
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
198
|
+
if (otpType === "email_verification" && otpFlow === "change_contact") {
|
|
199
|
+
const res = await verifyContactChange({
|
|
200
|
+
otpToken: otpToken || "",
|
|
201
|
+
code: otp,
|
|
202
|
+
})
|
|
203
|
+
if (!res?.success) {
|
|
204
|
+
throw new Error(res?.error || "Invalid code")
|
|
205
|
+
}
|
|
206
|
+
setEmailVerified(true)
|
|
207
|
+
} else {
|
|
208
|
+
const res = await verifyRegistrationOtp({
|
|
209
|
+
otpToken: otpToken || "",
|
|
210
|
+
code: otp,
|
|
211
|
+
countryCode,
|
|
212
|
+
skipRedirect: true,
|
|
213
|
+
})
|
|
214
|
+
if (!res?.success) {
|
|
215
|
+
throw new Error(res?.error || "Invalid code")
|
|
216
|
+
}
|
|
217
|
+
if (otpType === "phone_verification") {
|
|
218
|
+
setPhoneVerified(true)
|
|
219
|
+
}
|
|
220
|
+
if (otpType === "email_verification") {
|
|
221
|
+
setEmailVerified(true)
|
|
222
|
+
}
|
|
164
223
|
}
|
|
165
224
|
setOtpOpen(false)
|
|
166
|
-
if (otpType === "phone_verification") {
|
|
167
|
-
setPhoneVerified(true)
|
|
168
|
-
}
|
|
169
225
|
setMessage("Verification successful.")
|
|
170
226
|
window.location.reload()
|
|
171
227
|
} catch (err) {
|
|
@@ -187,6 +243,14 @@ export function useAccountProfileLogic(
|
|
|
187
243
|
}
|
|
188
244
|
}
|
|
189
245
|
|
|
246
|
+
function handleEmailChange(value: string) {
|
|
247
|
+
setEmail(value)
|
|
248
|
+
setEmailError(null)
|
|
249
|
+
if (normalizeEmail(value) !== normalizeEmail(customer.email)) {
|
|
250
|
+
setEmailVerified(false)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
190
254
|
const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(" ")
|
|
191
255
|
|
|
192
256
|
return {
|
|
@@ -196,6 +260,10 @@ export function useAccountProfileLogic(
|
|
|
196
260
|
phoneReady,
|
|
197
261
|
phoneError,
|
|
198
262
|
phoneVerified,
|
|
263
|
+
email,
|
|
264
|
+
handleEmailChange,
|
|
265
|
+
emailReady,
|
|
266
|
+
emailError,
|
|
199
267
|
emailVerified,
|
|
200
268
|
message,
|
|
201
269
|
state,
|
package/src/account-profile.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import type { OtpVerificationComponent } from "./otp-component"
|
|
|
5
5
|
import OtpVerificationModal from "./otp-verification-modal"
|
|
6
6
|
import { useAccountProfileLogic } from "./account-profile-logic"
|
|
7
7
|
import ProfilePhoneField from "./profile-phone-field"
|
|
8
|
+
import ProfileEmailField from "./profile-email-field"
|
|
8
9
|
import { acct } from "./account-theme"
|
|
9
10
|
|
|
10
11
|
export default function AccountProfile({
|
|
@@ -27,31 +28,16 @@ export default function AccountProfile({
|
|
|
27
28
|
|
|
28
29
|
<form action={profile.formAction} className={`${acct.card} p-6 space-y-4`}>
|
|
29
30
|
<Field label="Full name" name="full_name" defaultValue={profile.fullName} />
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{!profile.emailVerified && (
|
|
41
|
-
<button
|
|
42
|
-
type="button"
|
|
43
|
-
onClick={() => profile.sendVerifyOtp("email_verification")}
|
|
44
|
-
disabled={profile.otpPending}
|
|
45
|
-
className={`${acct.btnOutline} !py-2 !px-3`}
|
|
46
|
-
>
|
|
47
|
-
Verify email
|
|
48
|
-
</button>
|
|
49
|
-
)}
|
|
50
|
-
{profile.emailVerified && (
|
|
51
|
-
<span className="text-xs text-green-600 font-medium">Verified</span>
|
|
52
|
-
)}
|
|
53
|
-
</div>
|
|
54
|
-
</div>
|
|
31
|
+
<ProfileEmailField
|
|
32
|
+
email={profile.email}
|
|
33
|
+
onEmailChange={profile.handleEmailChange}
|
|
34
|
+
emailVerified={profile.emailVerified}
|
|
35
|
+
emailError={profile.emailError}
|
|
36
|
+
verifyPending={profile.otpPending}
|
|
37
|
+
onVerify={() => profile.sendVerifyOtp("email_verification")}
|
|
38
|
+
verifyButtonClassName={`${acct.btnOutline} !py-2 !px-3`}
|
|
39
|
+
editButtonClassName={`${acct.btnOutline} !py-2 !px-3`}
|
|
40
|
+
/>
|
|
55
41
|
|
|
56
42
|
<ProfilePhoneField
|
|
57
43
|
phone={profile.phone}
|
|
@@ -85,7 +71,7 @@ export default function AccountProfile({
|
|
|
85
71
|
? "Verify your email"
|
|
86
72
|
: "Verify your phone"
|
|
87
73
|
}
|
|
88
|
-
description={`Enter the code sent to your ${profile.otpType === "email_verification" ? "email" : "phone"}.`}
|
|
74
|
+
description={`Enter the code sent to your ${profile.otpType === "email_verification" ? profile.email || "email" : "phone"}.`}
|
|
89
75
|
otp={profile.otp}
|
|
90
76
|
onOtpChange={profile.setOtp}
|
|
91
77
|
error={profile.otpError}
|
package/src/auth-server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
verifyCustomerOTP,
|
|
10
10
|
} from "@pradip1995/commerce-core/data/customer-registration"
|
|
11
11
|
import { transferCart } from "@pradip1995/commerce-core/data/customer"
|
|
12
|
-
import { getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
|
|
12
|
+
import { getAuthHeaders, getCacheTag, setAuthToken } from "@pradip1995/commerce-core/data/cookies"
|
|
13
13
|
import { sendOTP, verifyOTP } from "@pradip1995/commerce-core/data/guest"
|
|
14
14
|
import { getGoogleOAuthCallbackUrl } from "@pradip1995/commerce-core/util/google-oauth"
|
|
15
15
|
import { revalidateTag } from "next/cache"
|
|
@@ -64,6 +64,13 @@ function getHeaders() {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
async function getAuthenticatedHeaders() {
|
|
68
|
+
return {
|
|
69
|
+
...getHeaders(),
|
|
70
|
+
...(await getAuthHeaders()),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
function getBaseUrl() {
|
|
68
75
|
return process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
|
|
69
76
|
}
|
|
@@ -296,6 +303,74 @@ export async function sendRegistrationOtp(
|
|
|
296
303
|
}
|
|
297
304
|
}
|
|
298
305
|
|
|
306
|
+
export async function requestContactChange(input: { email?: string; phone?: string }) {
|
|
307
|
+
const body = input.email
|
|
308
|
+
? { email: input.email.trim().toLowerCase() }
|
|
309
|
+
: { phone: input.phone }
|
|
310
|
+
|
|
311
|
+
const response = await fetch(`${getBaseUrl()}/store/customers/me/contact`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: await getAuthenticatedHeaders(),
|
|
314
|
+
body: JSON.stringify(body),
|
|
315
|
+
cache: "no-store",
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
const err = (await response.json().catch(() => ({}))) as { message?: string }
|
|
320
|
+
const message = err.message || "Failed to send verification code"
|
|
321
|
+
if (message.includes("Channel configuration not found")) {
|
|
322
|
+
return {
|
|
323
|
+
success: false as const,
|
|
324
|
+
error:
|
|
325
|
+
"Verification is not configured on the backend. Rebuild and restart the Medusa server with customer-registration OTP settings.",
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { success: false as const, error: message }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const data = (await response.json()) as { token?: string; expires_at?: string }
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
success: true as const,
|
|
335
|
+
token: data.token,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function verifyContactChange(input: { otpToken: string; code: string }) {
|
|
340
|
+
const response = await fetch(`${getBaseUrl()}/store/customers/me/contact/verify`, {
|
|
341
|
+
method: "POST",
|
|
342
|
+
headers: await getAuthenticatedHeaders(),
|
|
343
|
+
body: JSON.stringify({
|
|
344
|
+
token: input.otpToken,
|
|
345
|
+
code: input.code,
|
|
346
|
+
}),
|
|
347
|
+
cache: "no-store",
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const data = (await response.json().catch(() => ({}))) as {
|
|
351
|
+
message?: string
|
|
352
|
+
token?: string | null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
return {
|
|
357
|
+
success: false as const,
|
|
358
|
+
error: data.message || "Invalid verification code",
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (data.token) {
|
|
363
|
+
await setAuthToken(data.token)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const customerCacheTag = await getCacheTag("customers")
|
|
367
|
+
if (customerCacheTag) {
|
|
368
|
+
revalidateTag(customerCacheTag)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { success: true as const }
|
|
372
|
+
}
|
|
373
|
+
|
|
299
374
|
export async function initiateGoogleAuth(countryCode?: string) {
|
|
300
375
|
try {
|
|
301
376
|
const backendUrl = getBaseUrl()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { isEmailFieldLocked, isValidEmail, profileEmailAction } from "./profile-email"
|
|
5
|
+
|
|
6
|
+
export type ProfileEmailFieldProps = {
|
|
7
|
+
email: string
|
|
8
|
+
onEmailChange: (value: string) => void
|
|
9
|
+
emailVerified: boolean
|
|
10
|
+
emailError: string | null
|
|
11
|
+
verifyPending: boolean
|
|
12
|
+
onVerify: () => void
|
|
13
|
+
label?: string
|
|
14
|
+
wrapperClassName?: string
|
|
15
|
+
labelClassName?: string
|
|
16
|
+
rowClassName?: string
|
|
17
|
+
inputClassName?: string
|
|
18
|
+
verifyButtonClassName?: string
|
|
19
|
+
editButtonClassName?: string
|
|
20
|
+
verifiedClassName?: string
|
|
21
|
+
errorClassName?: string
|
|
22
|
+
hintClassName?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ProfileEmailField({
|
|
26
|
+
email,
|
|
27
|
+
onEmailChange,
|
|
28
|
+
emailVerified,
|
|
29
|
+
emailError,
|
|
30
|
+
verifyPending,
|
|
31
|
+
onVerify,
|
|
32
|
+
label = "Email",
|
|
33
|
+
wrapperClassName = "",
|
|
34
|
+
labelClassName = "block text-xs font-semibold uppercase tracking-wider text-muted mb-1.5",
|
|
35
|
+
rowClassName = "flex flex-wrap items-center gap-3",
|
|
36
|
+
inputClassName = "flex-1 min-w-[200px] border border-cart-border rounded-none px-3 py-2.5 text-sm bg-surface text-heading focus:outline-none focus:border-brand-accent",
|
|
37
|
+
verifyButtonClassName = "",
|
|
38
|
+
editButtonClassName = "",
|
|
39
|
+
verifiedClassName = "text-xs text-green-600 font-medium",
|
|
40
|
+
errorClassName = "text-sm text-red-600 mt-1.5",
|
|
41
|
+
hintClassName = "text-xs text-muted mt-1.5",
|
|
42
|
+
}: ProfileEmailFieldProps) {
|
|
43
|
+
const [editing, setEditing] = useState(false)
|
|
44
|
+
const locked = isEmailFieldLocked({ emailVerified, editing })
|
|
45
|
+
const emailReady = isValidEmail(email)
|
|
46
|
+
const action = profileEmailAction({ emailVerified, editing, emailReady })
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (emailVerified) setEditing(false)
|
|
50
|
+
}, [emailVerified])
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={wrapperClassName}>
|
|
54
|
+
<label className={labelClassName} htmlFor="account-profile-email">
|
|
55
|
+
{label}
|
|
56
|
+
</label>
|
|
57
|
+
<div className={rowClassName}>
|
|
58
|
+
<input
|
|
59
|
+
id="account-profile-email"
|
|
60
|
+
name="email"
|
|
61
|
+
type="email"
|
|
62
|
+
value={email}
|
|
63
|
+
onChange={(event) => onEmailChange(event.target.value)}
|
|
64
|
+
readOnly={locked}
|
|
65
|
+
placeholder="Enter your email"
|
|
66
|
+
autoComplete="email"
|
|
67
|
+
className={`${inputClassName}${locked ? " bg-surface-muted text-muted" : ""}`}
|
|
68
|
+
data-testid="account-profile-email"
|
|
69
|
+
/>
|
|
70
|
+
{action === "edit" ? (
|
|
71
|
+
<>
|
|
72
|
+
<span className={verifiedClassName}>Verified</span>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => setEditing(true)}
|
|
76
|
+
className={editButtonClassName || verifyButtonClassName}
|
|
77
|
+
data-testid="account-profile-edit-email"
|
|
78
|
+
>
|
|
79
|
+
Edit
|
|
80
|
+
</button>
|
|
81
|
+
</>
|
|
82
|
+
) : null}
|
|
83
|
+
{action === "verified" ? <span className={verifiedClassName}>Verified</span> : null}
|
|
84
|
+
{action === "verify" ? (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={onVerify}
|
|
88
|
+
disabled={verifyPending}
|
|
89
|
+
className={verifyButtonClassName}
|
|
90
|
+
data-testid="account-profile-verify-email"
|
|
91
|
+
>
|
|
92
|
+
{verifyPending ? "Sending…" : "Verify email"}
|
|
93
|
+
</button>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
{emailError ? (
|
|
97
|
+
<p className={errorClassName} role="alert">
|
|
98
|
+
{emailError}
|
|
99
|
+
</p>
|
|
100
|
+
) : null}
|
|
101
|
+
{action === "edit" ? (
|
|
102
|
+
<p className={hintClassName}>
|
|
103
|
+
Tap <strong>Edit</strong> to change this email.
|
|
104
|
+
</p>
|
|
105
|
+
) : action === "verify" ? (
|
|
106
|
+
<p className={hintClassName}>
|
|
107
|
+
Tap <strong>Verify email</strong> to receive an OTP at this address.
|
|
108
|
+
</p>
|
|
109
|
+
) : editing ? (
|
|
110
|
+
<p className={hintClassName}>
|
|
111
|
+
Update the email, then verify it again.
|
|
112
|
+
</p>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {
|
|
4
|
+
EMAIL_OTP_ERRORS,
|
|
5
|
+
isEmailFieldLocked,
|
|
6
|
+
isValidEmail,
|
|
7
|
+
normalizeEmail,
|
|
8
|
+
profileEmailAction,
|
|
9
|
+
resolveEmailOtpFlow,
|
|
10
|
+
type ProfileEmailAction,
|
|
11
|
+
type ResolveEmailOtpFlowResult,
|
|
12
|
+
} from "./profile-email.ts"
|
|
13
|
+
|
|
14
|
+
const NORMALIZE_CASES: { name: string; value: string | null | undefined; want: string }[] = [
|
|
15
|
+
{ name: "trims and lowercases a typed email", value: " Jay@Empire.COM ", want: "jay@empire.com" },
|
|
16
|
+
{ name: "empty string stays empty", value: "", want: "" },
|
|
17
|
+
{ name: "whitespace-only becomes empty", value: " ", want: "" },
|
|
18
|
+
{ name: "null becomes empty", value: null, want: "" },
|
|
19
|
+
{ name: "undefined becomes empty", value: undefined, want: "" },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
const VALID_CASES: { name: string; value: string | null | undefined; want: boolean }[] = [
|
|
23
|
+
{ name: "accepts a normal email", value: "jay@empire.com", want: true },
|
|
24
|
+
{ name: "accepts mixed-case with spaces", value: " Jay@Empire.COM ", want: true },
|
|
25
|
+
{ name: "rejects empty", value: "", want: false },
|
|
26
|
+
{ name: "rejects missing at-sign", value: "jayempire.com", want: false },
|
|
27
|
+
{ name: "rejects missing domain", value: "jay@", want: false },
|
|
28
|
+
{ name: "rejects phone-only customer empty email", value: null, want: false },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const FLOW_CASES: {
|
|
32
|
+
name: string
|
|
33
|
+
currentEmail: string | null | undefined
|
|
34
|
+
draftEmail: string
|
|
35
|
+
want: ResolveEmailOtpFlowResult
|
|
36
|
+
}[] = [
|
|
37
|
+
{
|
|
38
|
+
name: "phone-only user adding an email uses contact change",
|
|
39
|
+
currentEmail: "",
|
|
40
|
+
draftEmail: "jay@empire.com",
|
|
41
|
+
want: { ok: true, flow: "change_contact", email: "jay@empire.com" },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "missing current email uses contact change",
|
|
45
|
+
currentEmail: null,
|
|
46
|
+
draftEmail: "jay@empire.com",
|
|
47
|
+
want: { ok: true, flow: "change_contact", email: "jay@empire.com" },
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "same unverified email verifies the existing address",
|
|
51
|
+
currentEmail: "jay@empire.com",
|
|
52
|
+
draftEmail: "Jay@Empire.com",
|
|
53
|
+
want: { ok: true, flow: "verify_existing", email: "jay@empire.com" },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "changing to a different email uses contact change",
|
|
57
|
+
currentEmail: "old@empire.com",
|
|
58
|
+
draftEmail: "new@empire.com",
|
|
59
|
+
want: { ok: true, flow: "change_contact", email: "new@empire.com" },
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "empty draft is empty",
|
|
63
|
+
currentEmail: "",
|
|
64
|
+
draftEmail: " ",
|
|
65
|
+
want: { ok: false, reason: "empty" },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "malformed draft is invalid",
|
|
69
|
+
currentEmail: "",
|
|
70
|
+
draftEmail: "not-an-email",
|
|
71
|
+
want: { ok: false, reason: "invalid" },
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const LOCK_CASES: { name: string; emailVerified: boolean; editing: boolean; want: boolean }[] = [
|
|
76
|
+
{ name: "verified and not editing is locked", emailVerified: true, editing: false, want: true },
|
|
77
|
+
{ name: "verified while editing is unlocked so the user can type", emailVerified: true, editing: true, want: false },
|
|
78
|
+
{ name: "unverified empty email is never locked", emailVerified: false, editing: false, want: false },
|
|
79
|
+
{ name: "unverified stays unlocked even if editing flag is set", emailVerified: false, editing: true, want: false },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const ACTION_CASES: {
|
|
83
|
+
name: string
|
|
84
|
+
emailVerified: boolean
|
|
85
|
+
editing: boolean
|
|
86
|
+
emailReady: boolean
|
|
87
|
+
want: ProfileEmailAction
|
|
88
|
+
}[] = [
|
|
89
|
+
{
|
|
90
|
+
name: "phone-only empty field shows no verify button yet",
|
|
91
|
+
emailVerified: false,
|
|
92
|
+
editing: false,
|
|
93
|
+
emailReady: false,
|
|
94
|
+
want: "none",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "typed valid unverified email shows verify",
|
|
98
|
+
emailVerified: false,
|
|
99
|
+
editing: false,
|
|
100
|
+
emailReady: true,
|
|
101
|
+
want: "verify",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "verified email shows edit",
|
|
105
|
+
emailVerified: true,
|
|
106
|
+
editing: false,
|
|
107
|
+
emailReady: true,
|
|
108
|
+
want: "edit",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "editing a verified email keeps the verified label until the value changes",
|
|
112
|
+
emailVerified: true,
|
|
113
|
+
editing: true,
|
|
114
|
+
emailReady: true,
|
|
115
|
+
want: "verified",
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
describe("normalizeEmail", () => {
|
|
120
|
+
for (const tc of NORMALIZE_CASES) {
|
|
121
|
+
it(tc.name, () => {
|
|
122
|
+
assert.equal(normalizeEmail(tc.value), tc.want)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("isValidEmail", () => {
|
|
128
|
+
for (const tc of VALID_CASES) {
|
|
129
|
+
it(tc.name, () => {
|
|
130
|
+
assert.equal(isValidEmail(tc.value), tc.want)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("resolveEmailOtpFlow", () => {
|
|
136
|
+
for (const tc of FLOW_CASES) {
|
|
137
|
+
it(tc.name, () => {
|
|
138
|
+
assert.deepEqual(
|
|
139
|
+
resolveEmailOtpFlow({ currentEmail: tc.currentEmail, draftEmail: tc.draftEmail }),
|
|
140
|
+
tc.want
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe("isEmailFieldLocked", () => {
|
|
147
|
+
for (const tc of LOCK_CASES) {
|
|
148
|
+
it(tc.name, () => {
|
|
149
|
+
assert.equal(
|
|
150
|
+
isEmailFieldLocked({ emailVerified: tc.emailVerified, editing: tc.editing }),
|
|
151
|
+
tc.want
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe("profileEmailAction", () => {
|
|
158
|
+
for (const tc of ACTION_CASES) {
|
|
159
|
+
it(tc.name, () => {
|
|
160
|
+
assert.equal(
|
|
161
|
+
profileEmailAction({
|
|
162
|
+
emailVerified: tc.emailVerified,
|
|
163
|
+
editing: tc.editing,
|
|
164
|
+
emailReady: tc.emailReady,
|
|
165
|
+
}),
|
|
166
|
+
tc.want
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe("EMAIL_OTP_ERRORS", () => {
|
|
173
|
+
const CASES = [
|
|
174
|
+
{ name: "empty copy", reason: "empty" as const, want: "Please enter an email address before verifying." },
|
|
175
|
+
{ name: "invalid copy", reason: "invalid" as const, want: "Please enter a valid email address." },
|
|
176
|
+
]
|
|
177
|
+
for (const tc of CASES) {
|
|
178
|
+
it(tc.name, () => {
|
|
179
|
+
assert.equal(EMAIL_OTP_ERRORS[tc.reason], tc.want)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const EMAIL_REGEX = /^\S+@\S+\.\S+$/
|
|
2
|
+
|
|
3
|
+
export const EMAIL_OTP_ERRORS = {
|
|
4
|
+
empty: "Please enter an email address before verifying.",
|
|
5
|
+
invalid: "Please enter a valid email address.",
|
|
6
|
+
} as const
|
|
7
|
+
|
|
8
|
+
export function normalizeEmail(value: string | null | undefined): string {
|
|
9
|
+
return (value || "").trim().toLowerCase()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isValidEmail(value: string | null | undefined): boolean {
|
|
13
|
+
return EMAIL_REGEX.test(normalizeEmail(value))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type EmailOtpFlow = "verify_existing" | "change_contact"
|
|
17
|
+
|
|
18
|
+
export type ResolveEmailOtpFlowResult =
|
|
19
|
+
| { ok: true; flow: EmailOtpFlow; email: string }
|
|
20
|
+
| { ok: false; reason: "empty" | "invalid" }
|
|
21
|
+
|
|
22
|
+
export function resolveEmailOtpFlow(input: {
|
|
23
|
+
currentEmail: string | null | undefined
|
|
24
|
+
draftEmail: string | null | undefined
|
|
25
|
+
}): ResolveEmailOtpFlowResult {
|
|
26
|
+
const email = normalizeEmail(input.draftEmail)
|
|
27
|
+
if (!email) return { ok: false, reason: "empty" }
|
|
28
|
+
if (!isValidEmail(email)) return { ok: false, reason: "invalid" }
|
|
29
|
+
const current = normalizeEmail(input.currentEmail)
|
|
30
|
+
if (current && email === current) {
|
|
31
|
+
return { ok: true, flow: "verify_existing", email }
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, flow: "change_contact", email }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isEmailFieldLocked(input: {
|
|
37
|
+
emailVerified: boolean
|
|
38
|
+
editing: boolean
|
|
39
|
+
}): boolean {
|
|
40
|
+
return input.emailVerified && !input.editing
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ProfileEmailAction = "verify" | "edit" | "verified" | "none"
|
|
44
|
+
|
|
45
|
+
export function profileEmailAction(input: {
|
|
46
|
+
emailVerified: boolean
|
|
47
|
+
editing: boolean
|
|
48
|
+
emailReady: boolean
|
|
49
|
+
}): ProfileEmailAction {
|
|
50
|
+
if (isEmailFieldLocked(input)) return "edit"
|
|
51
|
+
if (input.emailVerified) return "verified"
|
|
52
|
+
if (input.emailReady) return "verify"
|
|
53
|
+
return "none"
|
|
54
|
+
}
|