Skip to content

Commit 2537e6d

Browse files
authored
Merge pull request #16 from ccwukong/forgot-password
feat. customer account Forgot password
2 parents a07d8cb + f8457d1 commit 2537e6d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2193
-104
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ DB_USER=root
200200
DB_PASS=<database user password>
201201
DB_NAME=<database name>
202202
JWT_TOKEN_SECRET=<a long random string that is used to sign JWT auth token>
203+
PASSWORD_LINK_JWT_TOKEN_SECRET=<a long random string that is used to sign JWT token for password reset>
203204
SESSION_COOKIE_SECRET=<a long random string that is used to sign cookie message>
204205
```
205206

app/contexts/tests/customerContext.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen } from '@testing-library/react'
22
import { useContext } from 'react'
3-
import { DatabaseRecordStatus } from '~/types'
3+
import { DatabaseRecordStatus, Role } from '~/types'
44
import CustomerContext from '../customerContext'
55

66
describe('CustomerContext', () => {
@@ -32,6 +32,7 @@ describe('CustomerContext', () => {
3232
avatar: 'https//',
3333
createdOn: 1,
3434
updatedOn: null,
35+
role: Role.Customer,
3536
status: DatabaseRecordStatus.Active,
3637
},
3738
storeSettings: {

app/models.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,6 @@ export class Installer {
134134
phone: '',
135135
avatar: '',
136136
role: Role.Admin,
137-
createdOn,
138-
updatedOn: null,
139-
status: DatabaseRecordStatus.Active,
140137
}
141138
}
142139
}
@@ -174,9 +171,6 @@ export class AdminAuthtication {
174171
lastName: userData.lastName,
175172
avatar: userData.avatar,
176173
role: userData.role,
177-
createdOn: userData.createdOn,
178-
updatedOn: userData.updatedOn,
179-
status: userData.status,
180174
}
181175
}
182176

@@ -303,9 +297,7 @@ export class CustomerAuthentication {
303297
firstName: userData.firstName,
304298
lastName: userData.lastName,
305299
avatar: userData.avatar,
306-
createdOn: userData.createdOn,
307-
updatedOn: userData.updatedOn,
308-
status: userData.status,
300+
role: Role.Customer,
309301
}
310302
}
311303

@@ -350,6 +342,49 @@ export class CustomerAuthentication {
350342
),
351343
)
352344
}
345+
346+
public static async resetPassword({
347+
email,
348+
newPwd,
349+
}: {
350+
email: string
351+
newPwd: string
352+
}): Promise<void> {
353+
const newSalt = makeStr(8)
354+
355+
await db
356+
.update(customer)
357+
.set({ password: md5(newPwd + newSalt), salt: newSalt })
358+
.where(
359+
and(
360+
eq(customer.email, email),
361+
eq(customer.status, DatabaseRecordStatus.Active),
362+
),
363+
)
364+
}
365+
366+
public static async getCustommerNameByEmailIfRegistered(
367+
email: string,
368+
): Promise<{ [key: string]: string } | null> {
369+
const res = await db
370+
.select()
371+
.from(customer)
372+
.where(
373+
and(
374+
eq(customer.email, email),
375+
eq(customer.status, DatabaseRecordStatus.Active),
376+
),
377+
)
378+
379+
if (res.length) {
380+
return {
381+
firstName: res[0].firstName,
382+
lastName: res[0].lastName,
383+
}
384+
} else {
385+
return null
386+
}
387+
}
353388
}
354389

355390
export class UserModel implements CRUDModel<UserPublicInfo> {
@@ -395,7 +430,7 @@ export class UserModel implements CRUDModel<UserPublicInfo> {
395430
.where(
396431
and(
397432
eq(user.id, id as string),
398-
ne(user.status, DatabaseRecordStatus.Deleted),
433+
ne(user.status, DatabaseRecordStatus.Deleted), // customer account could be disabled temporarily
399434
),
400435
)
401436

@@ -528,6 +563,7 @@ export class CustomerModel implements CRUDModel<UserPublicInfo> {
528563
firstName: res[0].firstName,
529564
lastName: res[0].lastName,
530565
avatar: res[0].avatar,
566+
role: Role.Customer,
531567
createdOn: res[0].createdOn,
532568
updatedOn: res[0].updatedOn,
533569
status: res[0].status,
@@ -553,6 +589,7 @@ export class CustomerModel implements CRUDModel<UserPublicInfo> {
553589
firstName: item.firstName,
554590
lastName: item.lastName,
555591
avatar: item.avatar,
592+
role: Role.Customer,
556593
createdOn: item.createdOn,
557594
updatedOn: item.updatedOn,
558595
status: item.status,
@@ -1039,6 +1076,7 @@ export class StoreConfig {
10391076
const data = await db
10401077
.select({
10411078
name: emailTemplate.name,
1079+
subject: emailTemplate.subject,
10421080
content: emailTemplate.content,
10431081
})
10441082
.from(emailTemplate)
@@ -1053,6 +1091,7 @@ export class StoreConfig {
10531091
const data = await db
10541092
.select({
10551093
name: emailTemplate.name,
1094+
subject: emailTemplate.subject,
10561095
content: emailTemplate.content,
10571096
})
10581097
.from(emailTemplate)
@@ -1067,10 +1106,12 @@ export class StoreConfig {
10671106

10681107
public static async createEmailTemplate(data: {
10691108
name: string
1109+
subject: string
10701110
content: string
10711111
}): Promise<string> {
10721112
const result = await db.insert(emailTemplate).values({
10731113
name: data.name,
1114+
subject: data.subject,
10741115
content: data.content,
10751116
status: DatabaseRecordStatus.Active,
10761117
})
@@ -1084,11 +1125,13 @@ export class StoreConfig {
10841125

10851126
public static async updateEmailTemplateByName(data: {
10861127
name: string
1128+
subject: string
10871129
content: string
10881130
}): Promise<void> {
10891131
await db
10901132
.update(emailTemplate)
10911133
.set({
1134+
subject: data.subject,
10921135
content: data.content,
10931136
})
10941137
.where(eq(emailTemplate.name, data.name))
@@ -1271,6 +1314,7 @@ export class OrderModel implements CRUDModel<OrderItem> {
12711314
}
12721315

12731316
async findMany(page: number, size: number): Promise<OrderItem[]> {
1317+
console.log(page, size)
12741318
return []
12751319
}
12761320

app/routes/account._index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ import {
1717
import { decode, encode, isValid } from '~/utils/jwt'
1818
import * as mocks from '~/utils/mocks'
1919

20-
export const meta: MetaFunction = () => {
20+
export const meta: MetaFunction = ({ data }) => {
2121
// eslint-disable-next-line react-hooks/rules-of-hooks
2222
const { t } = useTranslation()
23-
return [{ title: t('system.account_dashboard') }]
23+
return [
24+
{
25+
title: `${data?.data?.storeSettings.name} - ${t(
26+
'system.account_dashboard',
27+
)}`,
28+
},
29+
]
2430
}
2531

2632
export const loader = async ({ request }: LoaderFunctionArgs) => {

app/routes/account.settings.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,16 @@ import {
2626
} from '~/utils/exception'
2727
import { decode, isValid } from '~/utils/jwt'
2828

29-
export const meta: MetaFunction = () => {
29+
export const meta: MetaFunction = ({ data }) => {
3030
// eslint-disable-next-line react-hooks/rules-of-hooks
3131
const { t } = useTranslation()
32-
return [{ title: t('system.account_dashboard_settings') }]
32+
return [
33+
{
34+
title: `${data?.data?.storeSettings.name} - ${t(
35+
'system.account_dashboard_settings',
36+
)}`,
37+
},
38+
]
3339
}
3440

3541
export const loader = async ({ request }: LoaderFunctionArgs) => {

app/routes/admin.settings.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,11 +301,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
301301
} else if (body.get('intent') === 'create-email-template') {
302302
await StoreConfig.createEmailTemplate({
303303
name: String(body.get('name')),
304+
subject: String(body.get('subject')),
304305
content: String(body.get('content')),
305306
})
306307
} else if (body.get('intent') === 'update-email-template') {
307308
await StoreConfig.updateEmailTemplateByName({
308309
name: String(body.get('name')),
310+
subject: String(body.get('subject')),
309311
content: String(body.get('content')),
310312
})
311313
return json({ error: null, data: {} }) //for modal dismissal

app/routes/cart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ import {
2121
import { decode, isValid } from '~/utils/jwt'
2222
import * as mocks from '~/utils/mocks'
2323

24-
export const meta: MetaFunction = () => {
24+
export const meta: MetaFunction = ({ data }) => {
2525
// eslint-disable-next-line react-hooks/rules-of-hooks
2626
const { t } = useTranslation()
27-
return [{ title: t('system.cart') }]
27+
return [{ title: `${data?.data?.storeSettings.name} - ${t('system.cart')}` }]
2828
}
2929

3030
export const loader = async ({ request }: LoaderFunctionArgs) => {

app/routes/categories.$slug.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { useLoaderData } from '@remix-run/react'
88
import { Suspense } from 'react'
99
import StoreContext from '~/contexts/storeContext'
10-
import { Installer, ProductModel, StoreConfig } from '~/models'
10+
import { Installer, StoreConfig } from '~/models'
1111
import Skeleton from '~/themes/default/components/ui/storefront/Skeleton'
1212
import CategoryProductList from '~/themes/default/pages/storefront/CategoryProductList'
1313
import { CategoryItem, FatalErrorTypes, ProductPublicInfo } from '~/types'
@@ -34,7 +34,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
3434
if (!(await Installer.isInstalled())) {
3535
throw new StoreNotInstalledError()
3636
}
37-
const model = new ProductModel()
37+
3838
return json({
3939
error: null,
4040
data: {

app/routes/forgot-password.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { ActionFunctionArgs, MetaFunction } from '@remix-run/node'
2+
import { json, redirect } from '@remix-run/node'
3+
import { Suspense } from 'react'
4+
import { useTranslation } from 'react-i18next'
5+
import { CustomerAuthentication, Installer, StoreConfig } from '~/models'
6+
import Skeleton from '~/themes/default/components/ui/storefront/Skeleton'
7+
import ForgotPassword from '~/themes/default/pages/account/ForgotPassword'
8+
import { EmailTemplate, ExternalApiType, FatalErrorTypes } from '~/types'
9+
import sendEmail from '~/utils/email'
10+
import {
11+
JWTTokenSecretNotFoundException,
12+
StoreNotInstalledError,
13+
} from '~/utils/exception'
14+
import { encode } from '~/utils/jwt'
15+
16+
export const meta: MetaFunction = ({ data }) => {
17+
// eslint-disable-next-line react-hooks/rules-of-hooks
18+
const { t } = useTranslation()
19+
return [
20+
{
21+
title: `${data?.data?.storeSettings.name} - ${t(
22+
'system.forgot_password',
23+
)}`,
24+
},
25+
]
26+
}
27+
28+
export const loader = async () => {
29+
try {
30+
if (!(await Installer.isInstalled())) {
31+
throw new StoreNotInstalledError()
32+
}
33+
34+
if (!process.env.JWT_TOKEN_SECRET) {
35+
throw new JWTTokenSecretNotFoundException()
36+
}
37+
38+
return json({
39+
error: null,
40+
data: { storeSettings: await StoreConfig.getStoreInfo() },
41+
})
42+
} catch (e) {
43+
if (e instanceof StoreNotInstalledError) {
44+
return redirect('/install')
45+
} else if (e instanceof JWTTokenSecretNotFoundException) {
46+
// TODO: handle this seperately
47+
} else if (e?.code === FatalErrorTypes.DatabaseConnection) {
48+
return redirect('/error')
49+
}
50+
51+
return json({ error: e, data: null })
52+
}
53+
}
54+
55+
export const action = async ({ request }: ActionFunctionArgs) => {
56+
try {
57+
const body = await request.formData()
58+
const result =
59+
await CustomerAuthentication.getCustommerNameByEmailIfRegistered(
60+
String(body.get('email')),
61+
)
62+
63+
if (result) {
64+
const token = await encode(
65+
'1h',
66+
{ email: String(body.get('email')) },
67+
process.env.PASSWORD_LINK_JWT_TOKEN_SECRET!,
68+
)
69+
70+
//TODO: when installing store, insert this email template by default and make the template name uneditable
71+
const emailTemplate = await StoreConfig.getEmailTemplateByName(
72+
EmailTemplate.ForgotPassword,
73+
)
74+
const storeInfo = await StoreConfig.getStoreInfo()
75+
const emailApi = storeInfo.other?.apis[ExternalApiType.Email]
76+
77+
sendEmail({
78+
endpoint: emailApi!.endpoint as string,
79+
apiToken: emailApi!.token as string,
80+
subject: emailTemplate.subject,
81+
body: emailTemplate.content
82+
.replace('{{name}}', `${result.firstName} ${result.lastName}`)
83+
.replace(
84+
'{{link}}',
85+
`<a href="${
86+
request.url.replace('forgot-', 'reset-') + '?t=' + token
87+
}">${request.url.replace('forgot-', 'reset-') + '?t=' + token}</a>`,
88+
),
89+
from: storeInfo.email,
90+
sender: storeInfo.name,
91+
to: String(body.get('email')),
92+
})
93+
}
94+
95+
return json({ error: null, data: {} })
96+
} catch (e) {
97+
console.error(e) // TODO: replace this with a proper logger
98+
if (e?.code === FatalErrorTypes.DatabaseConnection) {
99+
return redirect('/error')
100+
}
101+
return json({ error: e, data: null })
102+
}
103+
}
104+
105+
export default function Index() {
106+
return (
107+
<Suspense fallback={<Skeleton />}>
108+
<ForgotPassword />
109+
</Suspense>
110+
)
111+
}

0 commit comments

Comments
 (0)