Skip to content

Commit

Permalink
feat/LBGG-565: Password recovery flow (#585)
Browse files Browse the repository at this point in the history
* Create `PasswordInput` component

* Create `ForgotPasswordCard` and add it to the `SiteNavBar`

* Create `useRecoverAccount` composable

* Create `<BasicAlert />` and `useModalAlert` composable. One test failing for some reason

* Use modal composable in confirm composable

* WIP: Use new composables in the `<ForgotPasswordCard />` component

* Fix the `<BasicAlert/>` component and tests

* Fix test for `<ForgotPasswordCard/>`

* Setup middleware and composables to validate the reset token and redirect to the account recovery page

* Layout account recovery page

* Refactor sign up/password related form work

* Enable resetting the password

* Rename `/recover-account` -> `/reset-password`

* Add basic missing composable tests

* Add disabled state to the submit button on the reset page

* Disabled reset password button if the email or username are invalid

* Redirect to the home page, when password change success modal is closed

* Rewrite middleware as async function. Fixes redirect bug.

* Use switch statement for readability
  • Loading branch information
erunks authored Feb 12, 2024
1 parent d4df2df commit 0108b91
Show file tree
Hide file tree
Showing 29 changed files with 1,022 additions and 148 deletions.
2 changes: 1 addition & 1 deletion components/blocks/cards/BasicAlert/BasicAlert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function getBasicAlertWrapper() {
}

beforeEach(() => {
const modalAlertState = useModalAlert()
const { state: modalAlertState } = useModalAlert()
modalAlertState.value = {
body: 'This is a test',
show: true,
Expand Down
31 changes: 11 additions & 20 deletions components/blocks/cards/BasicAlert/BasicAlert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,39 @@ import CardHeader from 'elements/cards/CardHeader/CardHeader.vue'
import CardBody from 'elements/cards/CardBody/CardBody.vue'
import { useModalAlert } from 'composables/useModalAlert'
const modalAlertState = useModalAlert()?.value
function close() {
modalAlertState.body = ''
modalAlertState.show = false
modalAlertState.title = ''
modalAlertState.type = ''
}
const { closeAlert, state } = useModalAlert()
</script>

<template>
<transition
v-if="modalAlertState.show"
v-if="state.show"
enter-active-class="transition-opacity duration-200"
leave-active-class="transition-opacity duration-200"
enter-to-class="opacity-100"
leave-to-class="opacity-0"
>
<BaseModal v-show="modalAlertState.show" @close="close">
<BaseModal v-show="state.show" @close="closeAlert">
<Card
id="basicModalAlert"
:class="['basic-modal-alert', modalAlertState.type]"
:class="['basic-modal-alert', state.type]"
data-testid="basic-modal-alert"
>
<CardHeader class="basic-modal-alert__header">
<i-svg-circle-info v-if="modalAlertState.type === 'info'" />
<i-svg-circle-check v-if="modalAlertState.type === 'success'" />
<i-svg-circle-exclamation v-if="modalAlertState.type === 'error'" />
<i-svg-triangle-exclamation
v-if="modalAlertState.type === 'warning'"
/>
<i-svg-circle-info v-if="state.type === 'info'" />
<i-svg-circle-check v-if="state.type === 'success'" />
<i-svg-circle-exclamation v-if="state.type === 'error'" />
<i-svg-triangle-exclamation v-if="state.type === 'warning'" />
<h2 class="basic-modal-alert__title">
{{ modalAlertState.title }}
{{ state.title }}
</h2>
<CloseButton
class="basic-modal-alert__close-button"
data-testid="basic-modal-alert-close-button"
@click.prevent="close"
@click.prevent="closeAlert"
/>
</CardHeader>
<CardBody class="basic-modal-alert__body">
{{ modalAlertState.body }}
{{ state.body }}
</CardBody>
</Card>
</BaseModal>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mount, enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { getByTestId } from 'root/testUtils'
import ForgotPasswordCard from './ForgotPasswordCard.vue'

function getForgotPasswordCardWrapper() {
return mount(ForgotPasswordCard)
}

const mockSuccessRecoverCreate = vi.fn(() => Promise.resolve({ ok: true }))

enableAutoUnmount(afterEach)

describe('<ForgotPasswordCard />', () => {
it('should render without crashing', () => {
const wrapper = getForgotPasswordCardWrapper()

expect(wrapper.isVisible()).toBe(true)
})

describe('when the close button is clicked', () => {
it('should emit the close event', async () => {
const wrapper = getForgotPasswordCardWrapper()

await getByTestId(wrapper, 'close-button').trigger('click')

expect(wrapper.emitted().close).toBeTruthy()
})
})

describe('when the cancel button is clicked', () => {
it('should emit the cancelClick event', async () => {
const wrapper = getForgotPasswordCardWrapper()

await getByTestId(wrapper, 'cancel-button').trigger('click')

expect(wrapper.emitted().cancelClick).toBeTruthy()
})
})

describe('when the reset password button is clicked', () => {
describe('when everything is successful', () => {
const username = 'strongbad'
const emailAddress = `${username}@homestarrunner.com`

it('should emit the close event', async () => {
vi.mock('lib/api/Account', () => ({
Account: function Account() {
this.recoverCreate = mockSuccessRecoverCreate
},
}))

const wrapper = getForgotPasswordCardWrapper()

const emailInput = getByTestId(wrapper, 'email-input')
await emailInput.setValue(emailAddress)

const usernameInput = getByTestId(wrapper, 'username-input')
await usernameInput.setValue(username)

await getByTestId(wrapper, 'reset-password-button').trigger('click')
await flushPromises()

expect(wrapper.emitted().close).toBeTruthy()
})
})

describe('when one or more fields are invalid', () => {
it('should not emit the close event', async () => {
const wrapper = getForgotPasswordCardWrapper()

await getByTestId(wrapper, 'reset-password-button').trigger('click')

expect(wrapper.emitted().close).toBeFalsy()
})
})
})
})
178 changes: 178 additions & 0 deletions components/blocks/cards/ForgotPasswordCard/ForgotPasswordCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { type Ref, ref } from 'vue'
import { isEmailValid, isUsernameValid } from 'lib/form_helpers'
import BaseButton from 'elements/buttons/BaseButton/BaseButton.vue'
import BaseInput from 'elements/inputs/BaseInput/BaseInput.vue'
import CloseButton from 'elements/buttons/CloseButton/CloseButton.vue'
import CardBody from 'elements/cards/CardBody/CardBody.vue'
import CardHeader from 'elements/cards/CardHeader/CardHeader.vue'
import Card from 'elements/cards/Card/Card.vue'
import { useRecoverAccount } from 'composables/api'
import { useModalAlert } from 'composables/useModalAlert'
interface ForgotPasswordCardPops {
modal?: boolean
}
interface ForgotPasswordCardState {
email: Ref<string>
username: Ref<string>
}
const emit = defineEmits<{
(event: 'close'): void
(event: 'cancelClick'): void
}>()
const props = withDefaults(defineProps<ForgotPasswordCardPops>(), {
modal: false,
})
const state: ForgotPasswordCardState = {
email: ref(''),
username: ref(''),
}
const emailValid = ref(true)
const usernameValid = ref(true)
const { showAlert } = useModalAlert()
function clearState() {
state.email.value = ''
state.username.value = ''
}
function cancel() {
clearState()
emit('cancelClick')
}
function resetPassword() {
useRecoverAccount(
{
email: state.email.value,
username: state.username.value,
},
{
onOkay: () => {
clearState()
emit('close')
showAlert({
body: 'If an account with that email and username exists, we will send you an email with a link to reset your password.',
title: 'Success!',
type: 'success',
})
},
},
)
}
</script>

<template>
<Card
id="forgotPasswordCard"
data-testid="forgot-password-card"
class="forgot-password-card"
>
<CardHeader class="forgot-password-card__header">
<div class="forgot-password-card__title">Forgot Password</div>
<CloseButton
v-show="props.modal"
data-testid="close-button"
@click.prevent="emit('close')"
/>
</CardHeader>
<CardBody>
<div class="forgot-password-card__body-wrapper">
<p class="instructions">
Enter the email and username associated with your account, and we'll
send you a link to reset your password.
</p>
<BaseInput
:model="state.email"
name="email"
type="text"
:style="{
'border-color': !emailValid ? 'rgb(185 28 28 / 1)' : '',
}"
placeholder="Email"
autocomplete="email"
data-testid="email-input"
@change="emailValid = isEmailValid(state.email)"
/>
<BaseInput
:model="state.username"
name="username"
type="text"
:style="{
'border-color': !usernameValid ? 'rgb(185 28 28 / 1)' : '',
}"
placeholder="Username"
autocomplete="username"
data-testid="username-input"
@change="usernameValid = isUsernameValid(state.username)"
/>
<BaseButton
id="reset-password-button"
class="reset-password-button"
data-testid="reset-password-button"
:disabled="
!(
state.email.value &&
state.username.value &&
emailValid &&
usernameValid
)
"
@click="resetPassword"
>
Reset Password
</BaseButton>
<BaseButton
class="cancel-button"
data-testid="cancel-button"
@click="cancel"
>
Cancel
</BaseButton>
</div>
</CardBody>
</Card>
</template>

<style lang="postcss" scoped>
.forgot-password-card {
@apply bg-white w-full max-w-xl;
& .forgot-password-card__header {
@apply flex flex-row space-x-3;
}
& .forgot-password-card__title {
@apply flex flex-1 justify-center px-3 py-2 rounded text-gray-900;
}
& .forgot-password-card__body-wrapper {
@apply flex flex-col space-y-3 pb-3 mb-3;
& .instructions {
@apply px-4 py-2;
}
}
.reset-password-button,
.cancel-button {
@apply flex flex-1 items-center justify-center fill-current bg-gray-100 text-gray-900 hover:bg-gray-200;
}
.reset-password-button:disabled {
@apply bg-gray-300 text-gray-500 cursor-not-allowed;
}
.cancel-button {
@apply bg-transparent border border-gray-300;
}
}
</style>
25 changes: 10 additions & 15 deletions components/blocks/cards/LogInCard/LogInCard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,6 @@ describe('<LogInCard />', () => {
})
})

describe('when the hide/show button is clicked', () => {
it('changes the password input type to be text', async () => {
const wrapper = getLogInCardWrapper()
const passwordInputElement = getHTMLElement(
getByTestId(wrapper, 'password-input'),
) as HTMLInputElement

expect(passwordInputElement.type).toBe('password')

await getByTestId(wrapper, 'hide-show-button').trigger('click')

expect(passwordInputElement.type).toBe('text')
})
})

// TODO: skip this for now
describe.skip('when enter key is released on the password input field', () => {
it('emits the close event', async () => {
Expand Down Expand Up @@ -138,6 +123,16 @@ describe('<LogInCard />', () => {
})
})

describe('when the forgot password button is clicked', () => {
it('emits the forgot password click event', async () => {
const wrapper = getLogInCardWrapper()

await getByTestId(wrapper, 'forgot-password-button').trigger('click')

expect(wrapper.emitted().forgotPasswordClick).toBeTruthy()
})
})

describe('when the sign up button is clicked', () => {
it('emits the sign up click event', async () => {
const wrapper = getLogInCardWrapper()
Expand Down
Loading

0 comments on commit 0108b91

Please sign in to comment.