Skip to content

Commit 2e11c92

Browse files
Show email confirmation status of the user in the settings page and resent the confirmation mail. (#733)
* This commit modifies the code to let the user know if their emails are confirmed. * This commit will help the user to see if their email is confirmed or not and request to resend the confirmation mail if they are not confirmed. * This commit replcaes the fetcher.form with fetcher.submit to prevent unwanted navigation. * This commit modifies the api to first check for seesion-based auth for web UI and falls back to JWT auth for API clients/tests. * This commit fixes the test failure associated with the test route tests\routes\api.users.me.boxes.$deviceId.spec.ts. * removes unwanted incrfesed timeout interval from the beforeall and afterall hooks in the file tests\routes\api.users.me.boxes.$deviceId.spec.ts * removes duplicate imports.. * updates the settings route to use intent-based action for resend verification. Also updates the api to serve only jwt clients. * removes unwanted comments from the test route "tests\routes\api.users.me.boxes.$deviceId.spec.ts" * fix: revert changes to api route for backwards compat * fix: remove emoji from translation files * fix: readd device security translation strings --------- Co-authored-by: David Scheidt <david.scheidt@opensenselab.org>
1 parent d1c6380 commit 2e11c92

File tree

5 files changed

+131
-32
lines changed

5 files changed

+131
-32
lines changed

app/routes/api.users.me.resend-email-confirmation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,18 @@ export const action: ActionFunction = async ({
99
try {
1010
const jwtResponse = await getUserFromJwt(request)
1111

12-
if (typeof jwtResponse === 'string')
12+
if (typeof jwtResponse === 'string') {
1313
return StandardResponse.forbidden(
1414
'Invalid JWT authorization. Please sign in to obtain new JWT.',
1515
)
16+
}
1617

1718
const result = await resendEmailConfirmation(jwtResponse)
18-
if (result === 'already_confirmed')
19+
if (result === 'already_confirmed') {
1920
return StandardResponse.unprocessableContent(
2021
`Email address ${jwtResponse.email} is already confirmed.`,
2122
)
23+
}
2224

2325
return StandardResponse.ok({
2426
code: 'Ok',

app/routes/settings.account.tsx

Lines changed: 103 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { CheckLine, OctagonAlert } from 'lucide-react'
12
import { useEffect, useRef, useState } from 'react'
23
import { useTranslation } from 'react-i18next'
34
import {
45
Form,
56
useActionData,
7+
useFetcher,
68
useLoaderData,
79
data,
810
redirect,
@@ -29,6 +31,7 @@ import {
2931
SelectValue,
3032
} from '~/components/ui/select'
3133
import { useToast } from '~/components/ui/use-toast'
34+
import { resendEmailConfirmation } from '~/lib/user-service.server'
3235
import {
3336
getUserByEmail,
3437
updateUserName,
@@ -39,11 +42,9 @@ import { getUserEmail, getUserId } from '~/utils/session.server'
3942

4043
//*****************************************************
4144
export async function loader({ request }: LoaderFunctionArgs) {
42-
// If user is not logged in, redirect to home
4345
const userId = await getUserId(request)
4446
if (!userId) return redirect('/')
4547

46-
// Get user email and load user data
4748
const userEmail = await getUserEmail(request)
4849
invariant(userEmail, `Email not found!`)
4950
const userData = await getUserByEmail(userEmail)
@@ -53,39 +54,71 @@ export async function loader({ request }: LoaderFunctionArgs) {
5354
//*****************************************************
5455
export async function action({ request }: ActionFunctionArgs) {
5556
const formData = await request.formData()
56-
const { name, passwordUpdate, email, language } = Object.fromEntries(formData)
57+
const intent = formData.get('intent')
58+
59+
if (intent === 'resend-verification') {
60+
const userEmail = await getUserEmail(request)
61+
if (!userEmail) {
62+
return data(
63+
{ intent: 'resend-verification', code: 'Forbidden' },
64+
{ status: 403 },
65+
)
66+
}
67+
const user = await getUserByEmail(userEmail)
68+
if (!user) {
69+
return data(
70+
{ intent: 'resend-verification', code: 'Forbidden' },
71+
{ status: 403 },
72+
)
73+
}
5774

58-
const errors = {
59-
name: name ? null : 'Invalid name',
60-
email: email ? null : 'Invalid email',
61-
passwordUpdate: passwordUpdate ? null : 'Password is required',
75+
try {
76+
const result = await resendEmailConfirmation(user)
77+
if (result === 'already_confirmed') {
78+
return data(
79+
{ intent: 'resend-verification', code: 'UnprocessableContent' },
80+
{ status: 422 },
81+
)
82+
}
83+
return data(
84+
{ intent: 'resend-verification', code: 'Ok' },
85+
{ status: 200 },
86+
)
87+
} catch (err) {
88+
console.warn(err)
89+
return data(
90+
{ intent: 'resend-verification', code: 'Error' },
91+
{ status: 500 },
92+
)
93+
}
6294
}
6395

96+
const { name, passwordUpdate, email, language } = Object.fromEntries(formData)
97+
6498
invariant(typeof name === 'string', 'name must be a string')
6599
invariant(typeof email === 'string', 'email must be a string')
66100
invariant(typeof passwordUpdate === 'string', 'password must be a string')
67101
invariant(typeof language === 'string', 'language must be a string')
68102

69-
// Validate password
70-
if (errors.passwordUpdate) {
103+
if (!passwordUpdate) {
71104
return data(
72105
{
106+
intent: 'update-profile',
73107
errors: {
74108
name: null,
75109
email: null,
76-
passwordUpdate: errors.passwordUpdate,
110+
passwordUpdate: 'Password is required',
77111
},
78-
status: 400,
79112
},
80113
{ status: 400 },
81114
)
82115
}
83116

84117
const user = await verifyLogin(email, passwordUpdate)
85-
// If password is invalid
86118
if (!user) {
87119
return data(
88120
{
121+
intent: 'update-profile',
89122
errors: {
90123
name: null,
91124
email: null,
@@ -96,13 +129,12 @@ export async function action({ request }: ActionFunctionArgs) {
96129
)
97130
}
98131

99-
// Update locale and name
100132
await updateUserlocale(email, language)
101133
await updateUserName(email, name)
102134

103-
// Return success response
104135
return data(
105136
{
137+
intent: 'update-profile',
106138
errors: {
107139
name: null,
108140
email: null,
@@ -115,31 +147,49 @@ export async function action({ request }: ActionFunctionArgs) {
115147

116148
//*****************************************************
117149
export default function EditUserProfilePage() {
118-
const userData = useLoaderData<typeof loader>() // Load user data
150+
const userData = useLoaderData<typeof loader>()
119151
const actionData = useActionData<typeof action>()
152+
const fetcher = useFetcher<typeof action>()
120153
const [lang, setLang] = useState(userData?.language || 'en_US')
121154
const [name, setName] = useState(userData?.name || '')
122-
const passwordUpdRef = useRef<HTMLInputElement>(null) // For password update focus
155+
const passwordUpdRef = useRef<HTMLInputElement>(null)
123156
const { toast } = useToast()
124157
const { t } = useTranslation('settings')
125158

159+
// Handle profile update responses
126160
useEffect(() => {
127-
// Handle invalid password update error
128-
if (actionData && actionData?.errors?.passwordUpdate) {
161+
if (!actionData || actionData.intent !== 'update-profile') return
162+
if (!('errors' in actionData)) return
163+
164+
if (actionData.errors?.passwordUpdate) {
129165
toast({
130166
title: t('invalid_password'),
131167
variant: 'destructive',
132168
})
133169
passwordUpdRef.current?.focus()
134-
}
135-
// Show success toast if profile updated
136-
if (actionData && !actionData?.errors?.passwordUpdate) {
170+
} else {
137171
toast({
138172
title: t('profile_successfully_updated'),
139173
variant: 'success',
140174
})
141175
}
142-
}, [actionData, toast])
176+
}, [actionData, toast, t])
177+
178+
// Handle resend verification response (via fetcher)
179+
useEffect(() => {
180+
if (fetcher.state !== 'idle' || !fetcher.data) return
181+
if (fetcher.data.intent !== 'resend-verification') return
182+
if (!('code' in fetcher.data)) return
183+
184+
const { code } = fetcher.data
185+
if (code === 'Ok') {
186+
toast({ title: t('verification_email_sent'), variant: 'success' })
187+
} else if (code === 'UnprocessableContent') {
188+
toast({ title: t('email_already_confirmed'), variant: 'default' })
189+
} else {
190+
toast({ title: t('verification_email_failed'), variant: 'destructive' })
191+
}
192+
}, [fetcher.state, fetcher.data, toast, t])
143193

144194
return (
145195
<Form method="post" className="space-y-6" noValidate>
@@ -168,9 +218,39 @@ export default function EditUserProfilePage() {
168218
name="email"
169219
placeholder={t('enter_email')}
170220
type="email"
171-
readOnly={true}
172221
defaultValue={userData?.email}
173222
/>
223+
{userData?.emailIsConfirmed ? (
224+
<p className="flex items-center gap-1 text-sm text-green-500 dark:text-green-300">
225+
<span className="inline-flex gap-1">
226+
<CheckLine /> {t('email_confirmed')}
227+
</span>
228+
</p>
229+
) : (
230+
<div className="flex items-center justify-between">
231+
<p className="dark:text-amber-400 flex items-center gap-1 text-sm text-orange-500">
232+
<span className="inline-flex gap-1">
233+
<OctagonAlert /> {t('email_not_confirmed')}
234+
</span>
235+
</p>
236+
<Button
237+
type="button"
238+
variant="default"
239+
size="sm"
240+
disabled={fetcher.state === 'submitting'}
241+
onClick={() => {
242+
void fetcher.submit(
243+
{ intent: 'resend-verification' },
244+
{ method: 'post' },
245+
)
246+
}}
247+
>
248+
{fetcher.state === 'submitting'
249+
? t('sending')
250+
: t('resend_verification')}
251+
</Button>
252+
</div>
253+
)}
174254
</div>
175255
<div className="grid gap-2">
176256
<Label htmlFor="language">{t('language')}</Label>
@@ -203,7 +283,6 @@ export default function EditUserProfilePage() {
203283
<CardFooter>
204284
<Button
205285
type="submit"
206-
// Disable button if no changes were made
207286
disabled={name === userData?.name && lang === userData?.language}
208287
>
209288
{t('save_changes')}

public/locales/de/settings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@
2929
"enter_name": "Gib deinen Namen ein",
3030
"email": "E-Mail",
3131
"enter_email": "Gib deine E-Mail-Adresse ein",
32+
"email_confirmed": "E-Mail bestätigt",
33+
"email_not_confirmed": "E-Mail nicht bestätigt. Bitte überprüfe deinen Posteingang.",
34+
"resend_verification": "Bestätigungs-E-Mail erneut senden",
35+
"sending": "Wird gesendet...",
36+
"verification_email_sent": "Bestätigungs-E-Mail gesendet",
37+
"verification_email_failed": "Bestätigungs-E-Mail konnte nicht gesendet werden.",
38+
"email_already_confirmed": "E-Mail ist bereits bestätigt.",
3239
"language": "Sprache",
3340
"select_language": "Wähle die Sprache aus",
3441
"confirm_password": "Bestätige dein Passwort",

public/locales/en/settings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"account": "Account",
44
"password": "Password",
55
"delete_account": "Delete Account",
6+
67
"profile_updated": "Profile updated",
78
"profile_updated_description": "Your profile has been updated successfully.",
89
"something_went_wrong": "Something went wrong.",
@@ -17,10 +18,12 @@
1718
"if_activated_public_3": ".",
1819
"change_profile_photo": "Change profile photo",
1920
"save_changes": "Save changes",
21+
2022
"profile_photo": "Profile photo",
2123
"save_photo": "Save Photo",
2224
"reset": "Reset",
2325
"change": "Change",
26+
2427
"invalid_password": "Invalid password",
2528
"profile_successfully_updated": "Profile successfully updated.",
2629
"account_information": "Account Information",
@@ -29,10 +32,18 @@
2932
"enter_name": "Enter your name",
3033
"email": "Email",
3134
"enter_email": "Enter your email",
35+
"email_confirmed": "Email confirmed",
36+
"email_not_confirmed": "Email not confirmed. Please check your inbox.",
37+
"resend_verification": "Resend confirmation email",
38+
"sending": "Sending...",
39+
"verification_email_sent": "Verification email sent",
40+
"verification_email_failed": "Failed to send verification email",
41+
"email_already_confirmed": "Email is already confirmed",
3242
"language": "Language",
3343
"select_language": "Select language",
3444
"confirm_password": "Confirm password",
3545
"enter_current_password": "Enter your current password",
46+
3647
"try_again": "Please try again.",
3748
"update_password": "Update Password",
3849
"update_password_description": "Enter your current password and a new password to update your account password.",
@@ -47,6 +58,7 @@
4758
"new_passwords_do_not_match": "New passwords do not match.",
4859
"current_password_incorret": "Current password is incorrect.",
4960
"password_updated_successfully": "Password updated successfully.",
61+
5062
"delete_account_description": "Deleting your account will permanently remove all of your data from our servers. This action cannot be undone.",
5163
"enter_password": "Enter your password",
5264
"device_security": {

tests/routes/api.users.me.boxes.$deviceId.spec.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { type User } from '~/schema'
1010

1111
const BOX_TEST_USER = generateTestUserCredentials()
1212
const BOX_TEST_USER_BOX = {
13-
name: `${BOX_TEST_USER}s Box`,
13+
name: `${BOX_TEST_USER.name}s Box`,
1414
exposure: 'outdoor',
1515
expiresAt: null,
1616
tags: [],
@@ -23,11 +23,9 @@ const BOX_TEST_USER_BOX = {
2323

2424
const OTHER_TEST_USER = generateTestUserCredentials()
2525

26-
// TODO Give the users some boxes to test with
27-
2826
describe('openSenseMap API Routes: /users', () => {
2927
describe('/me/boxes/:deviceId', () => {
30-
describe('GET', async () => {
28+
describe('GET', () => {
3129
let jwt: string = ''
3230
let otherJwt: string = ''
3331
let deviceId: string = ''
@@ -53,7 +51,7 @@ describe('openSenseMap API Routes: /users', () => {
5351

5452
const device = await createDevice(BOX_TEST_USER_BOX, (user as User).id)
5553
deviceId = device.id
56-
})
54+
})
5755

5856
it('should let users retrieve one of their boxes with all fields', async () => {
5957
// Act: Get single box
@@ -70,6 +68,7 @@ describe('openSenseMap API Routes: /users', () => {
7068
// Assert: Response for single box
7169
expect(singleBoxResponse.status).toBe(200)
7270
})
71+
7372
it('should deny to retrieve a box of other user', async () => {
7473
// Arrange
7574
const forbiddenRequest = new Request(
@@ -96,7 +95,7 @@ describe('openSenseMap API Routes: /users', () => {
9695
// delete the valid test user
9796
await deleteUserByEmail(BOX_TEST_USER.email)
9897
await deleteUserByEmail(OTHER_TEST_USER.email)
99-
})
98+
})
10099
})
101100
})
102101
})

0 commit comments

Comments
 (0)