Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/routes/api.users.me.resend-email-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ export const action: ActionFunction = async ({
try {
const jwtResponse = await getUserFromJwt(request)

if (typeof jwtResponse === 'string')
if (typeof jwtResponse === 'string') {
return StandardResponse.forbidden(
'Invalid JWT authorization. Please sign in to obtain new JWT.',
)
}

const result = await resendEmailConfirmation(jwtResponse)
if (result === 'already_confirmed')
if (result === 'already_confirmed') {
return StandardResponse.unprocessableContent(
`Email address ${jwtResponse.email} is already confirmed.`,
)
}

return StandardResponse.ok({
code: 'Ok',
Expand Down
127 changes: 103 additions & 24 deletions app/routes/settings.account.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { CheckLine, OctagonAlert } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Form,
useActionData,
useFetcher,
useLoaderData,
data,
redirect,
Expand All @@ -29,6 +31,7 @@ import {
SelectValue,
} from '~/components/ui/select'
import { useToast } from '~/components/ui/use-toast'
import { resendEmailConfirmation } from '~/lib/user-service.server'
import {
getUserByEmail,
updateUserName,
Expand All @@ -39,11 +42,9 @@ import { getUserEmail, getUserId } from '~/utils/session.server'

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

// Get user email and load user data
const userEmail = await getUserEmail(request)
invariant(userEmail, `Email not found!`)
const userData = await getUserByEmail(userEmail)
Expand All @@ -53,39 +54,71 @@ export async function loader({ request }: LoaderFunctionArgs) {
//*****************************************************
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const { name, passwordUpdate, email, language } = Object.fromEntries(formData)
const intent = formData.get('intent')

if (intent === 'resend-verification') {
const userEmail = await getUserEmail(request)
if (!userEmail) {
return data(
{ intent: 'resend-verification', code: 'Forbidden' },
{ status: 403 },
)
}
const user = await getUserByEmail(userEmail)
if (!user) {
return data(
{ intent: 'resend-verification', code: 'Forbidden' },
{ status: 403 },
)
}

const errors = {
name: name ? null : 'Invalid name',
email: email ? null : 'Invalid email',
passwordUpdate: passwordUpdate ? null : 'Password is required',
try {
const result = await resendEmailConfirmation(user)
if (result === 'already_confirmed') {
return data(
{ intent: 'resend-verification', code: 'UnprocessableContent' },
{ status: 422 },
)
}
return data(
{ intent: 'resend-verification', code: 'Ok' },
{ status: 200 },
)
} catch (err) {
console.warn(err)
return data(
{ intent: 'resend-verification', code: 'Error' },
{ status: 500 },
)
}
}

const { name, passwordUpdate, email, language } = Object.fromEntries(formData)

invariant(typeof name === 'string', 'name must be a string')
invariant(typeof email === 'string', 'email must be a string')
invariant(typeof passwordUpdate === 'string', 'password must be a string')
invariant(typeof language === 'string', 'language must be a string')

// Validate password
if (errors.passwordUpdate) {
if (!passwordUpdate) {
return data(
{
intent: 'update-profile',
errors: {
name: null,
email: null,
passwordUpdate: errors.passwordUpdate,
passwordUpdate: 'Password is required',
},
status: 400,
},
{ status: 400 },
)
}

const user = await verifyLogin(email, passwordUpdate)
// If password is invalid
if (!user) {
return data(
{
intent: 'update-profile',
errors: {
name: null,
email: null,
Expand All @@ -96,13 +129,12 @@ export async function action({ request }: ActionFunctionArgs) {
)
}

// Update locale and name
await updateUserlocale(email, language)
await updateUserName(email, name)

// Return success response
return data(
{
intent: 'update-profile',
errors: {
name: null,
email: null,
Expand All @@ -115,31 +147,49 @@ export async function action({ request }: ActionFunctionArgs) {

//*****************************************************
export default function EditUserProfilePage() {
const userData = useLoaderData<typeof loader>() // Load user data
const userData = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const fetcher = useFetcher<typeof action>()
const [lang, setLang] = useState(userData?.language || 'en_US')
const [name, setName] = useState(userData?.name || '')
const passwordUpdRef = useRef<HTMLInputElement>(null) // For password update focus
const passwordUpdRef = useRef<HTMLInputElement>(null)
const { toast } = useToast()
const { t } = useTranslation('settings')

// Handle profile update responses
useEffect(() => {
// Handle invalid password update error
if (actionData && actionData?.errors?.passwordUpdate) {
if (!actionData || actionData.intent !== 'update-profile') return
if (!('errors' in actionData)) return

if (actionData.errors?.passwordUpdate) {
toast({
title: t('invalid_password'),
variant: 'destructive',
})
passwordUpdRef.current?.focus()
}
// Show success toast if profile updated
if (actionData && !actionData?.errors?.passwordUpdate) {
} else {
toast({
title: t('profile_successfully_updated'),
variant: 'success',
})
}
}, [actionData, toast])
}, [actionData, toast, t])

// Handle resend verification response (via fetcher)
useEffect(() => {
if (fetcher.state !== 'idle' || !fetcher.data) return
if (fetcher.data.intent !== 'resend-verification') return
if (!('code' in fetcher.data)) return

const { code } = fetcher.data
if (code === 'Ok') {
toast({ title: t('verification_email_sent'), variant: 'success' })
} else if (code === 'UnprocessableContent') {
toast({ title: t('email_already_confirmed'), variant: 'default' })
} else {
toast({ title: t('verification_email_failed'), variant: 'destructive' })
}
}, [fetcher.state, fetcher.data, toast, t])

return (
<Form method="post" className="space-y-6" noValidate>
Expand Down Expand Up @@ -168,9 +218,39 @@ export default function EditUserProfilePage() {
name="email"
placeholder={t('enter_email')}
type="email"
readOnly={true}
defaultValue={userData?.email}
/>
{userData?.emailIsConfirmed ? (
<p className="flex items-center gap-1 text-sm text-green-500 dark:text-green-300">
<span className="inline-flex gap-1">
<CheckLine /> {t('email_confirmed')}
</span>
</p>
) : (
<div className="flex items-center justify-between">
<p className="dark:text-amber-400 flex items-center gap-1 text-sm text-orange-500">
<span className="inline-flex gap-1">
<OctagonAlert /> {t('email_not_confirmed')}
</span>
</p>
<Button
type="button"
variant="default"
size="sm"
disabled={fetcher.state === 'submitting'}
onClick={() => {
void fetcher.submit(
{ intent: 'resend-verification' },
{ method: 'post' },
)
}}
>
{fetcher.state === 'submitting'
? t('sending')
: t('resend_verification')}
</Button>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="language">{t('language')}</Label>
Expand Down Expand Up @@ -203,7 +283,6 @@ export default function EditUserProfilePage() {
<CardFooter>
<Button
type="submit"
// Disable button if no changes were made
disabled={name === userData?.name && lang === userData?.language}
>
{t('save_changes')}
Expand Down
7 changes: 7 additions & 0 deletions public/locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
"enter_name": "Gib deinen Namen ein",
"email": "E-Mail",
"enter_email": "Gib deine E-Mail-Adresse ein",
"email_confirmed": "E-Mail bestätigt",
"email_not_confirmed": "E-Mail nicht bestätigt. Bitte überprüfe deinen Posteingang.",
"resend_verification": "Bestätigungs-E-Mail erneut senden",
"sending": "Wird gesendet...",
"verification_email_sent": "Bestätigungs-E-Mail gesendet",
"verification_email_failed": "Bestätigungs-E-Mail konnte nicht gesendet werden.",
"email_already_confirmed": "E-Mail ist bereits bestätigt.",
"language": "Sprache",
"select_language": "Wähle die Sprache aus",
"confirm_password": "Bestätige dein Passwort",
Expand Down
12 changes: 12 additions & 0 deletions public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"account": "Account",
"password": "Password",
"delete_account": "Delete Account",

"profile_updated": "Profile updated",
"profile_updated_description": "Your profile has been updated successfully.",
"something_went_wrong": "Something went wrong.",
Expand All @@ -17,10 +18,12 @@
"if_activated_public_3": ".",
"change_profile_photo": "Change profile photo",
"save_changes": "Save changes",

"profile_photo": "Profile photo",
"save_photo": "Save Photo",
"reset": "Reset",
"change": "Change",

"invalid_password": "Invalid password",
"profile_successfully_updated": "Profile successfully updated.",
"account_information": "Account Information",
Expand All @@ -29,10 +32,18 @@
"enter_name": "Enter your name",
"email": "Email",
"enter_email": "Enter your email",
"email_confirmed": "Email confirmed",
"email_not_confirmed": "Email not confirmed. Please check your inbox.",
"resend_verification": "Resend confirmation email",
"sending": "Sending...",
"verification_email_sent": "Verification email sent",
"verification_email_failed": "Failed to send verification email",
"email_already_confirmed": "Email is already confirmed",
"language": "Language",
"select_language": "Select language",
"confirm_password": "Confirm password",
"enter_current_password": "Enter your current password",

"try_again": "Please try again.",
"update_password": "Update Password",
"update_password_description": "Enter your current password and a new password to update your account password.",
Expand All @@ -47,6 +58,7 @@
"new_passwords_do_not_match": "New passwords do not match.",
"current_password_incorret": "Current password is incorrect.",
"password_updated_successfully": "Password updated successfully.",

"delete_account_description": "Deleting your account will permanently remove all of your data from our servers. This action cannot be undone.",
"enter_password": "Enter your password",
"device_security": {
Expand Down
11 changes: 5 additions & 6 deletions tests/routes/api.users.me.boxes.$deviceId.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { type User } from '~/schema'

const BOX_TEST_USER = generateTestUserCredentials()
const BOX_TEST_USER_BOX = {
name: `${BOX_TEST_USER}s Box`,
name: `${BOX_TEST_USER.name}s Box`,
exposure: 'outdoor',
expiresAt: null,
tags: [],
Expand All @@ -23,11 +23,9 @@ const BOX_TEST_USER_BOX = {

const OTHER_TEST_USER = generateTestUserCredentials()

// TODO Give the users some boxes to test with

describe('openSenseMap API Routes: /users', () => {
describe('/me/boxes/:deviceId', () => {
describe('GET', async () => {
describe('GET', () => {
let jwt: string = ''
let otherJwt: string = ''
let deviceId: string = ''
Expand All @@ -53,7 +51,7 @@ describe('openSenseMap API Routes: /users', () => {

const device = await createDevice(BOX_TEST_USER_BOX, (user as User).id)
deviceId = device.id
})
})

it('should let users retrieve one of their boxes with all fields', async () => {
// Act: Get single box
Expand All @@ -70,6 +68,7 @@ describe('openSenseMap API Routes: /users', () => {
// Assert: Response for single box
expect(singleBoxResponse.status).toBe(200)
})

it('should deny to retrieve a box of other user', async () => {
// Arrange
const forbiddenRequest = new Request(
Expand All @@ -96,7 +95,7 @@ describe('openSenseMap API Routes: /users', () => {
// delete the valid test user
await deleteUserByEmail(BOX_TEST_USER.email)
await deleteUserByEmail(OTHER_TEST_USER.email)
})
})
})
})
})
Loading