Skip to content

feat(account): add forgot password page #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 37 commits into
base: release/v0.2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2b7260a
[POND-1] add first components
aheartforspinach Feb 26, 2025
1eb5691
[POND-1] add wishlist icon in header
aheartforspinach Feb 26, 2025
a72d0b7
[POND-1] add shadcn
aheartforspinach Feb 26, 2025
075d749
[POND-1] push broken stuff
aheartforspinach Feb 27, 2025
9610f87
[POND-1] fix import topic
aheartforspinach Feb 28, 2025
110fa93
[POND-1] add inner and outer
aheartforspinach Mar 7, 2025
3d08d23
[POND-5] add button componentm extract login
aheartforspinach Mar 7, 2025
c42f5d1
[POND-5] add login form
aheartforspinach Mar 10, 2025
1720218
[POND-5] remove dark variables, replace zinc with gray
aheartforspinach Mar 10, 2025
20fd016
[POND-5] hunt error
aheartforspinach Mar 10, 2025
4ab1f7e
[POND-5] fix issue
aheartforspinach Mar 10, 2025
325841a
[POND-5] add auto animate
aheartforspinach Mar 10, 2025
41dbea8
[POND-5] fix issues
aheartforspinach Mar 10, 2025
9f1cd39
[POND-5] add password forgotten link
aheartforspinach Mar 10, 2025
9c247bd
fix typo
aheartforspinach Mar 27, 2025
3c705bd
install ducktory
aheartforspinach Mar 27, 2025
6fba4a6
add ducktory
aheartforspinach Apr 1, 2025
3d3366c
add more stories
aheartforspinach Apr 1, 2025
1825783
fix accordion
aheartforspinach Apr 1, 2025
ca3f38e
update branch
aheartforspinach Apr 1, 2025
484853d
adjust loogin
aheartforspinach Apr 1, 2025
8ae730a
remove return
aheartforspinach Apr 1, 2025
a392ee6
revert change
aheartforspinach Apr 1, 2025
c9394dc
delete unneeded stuff
aheartforspinach Apr 2, 2025
89d952f
use formatLink
aheartforspinach Apr 4, 2025
9d15c28
Merge remote-tracking branch 'origin/release/v0.2' into feature/POND-…
aheartforspinach Apr 4, 2025
b4431f4
fix localepath
aheartforspinach Apr 7, 2025
92d4e5d
[POND-10]: add recover password page
MorennMcFly Apr 16, 2025
9df1023
[POND-10]: add documentation
MorennMcFly Apr 16, 2025
63b6495
Merge branch 'release/v0.2' into feature/POND-10-forgot-password
MorennMcFly Apr 16, 2025
51fc324
Apply suggestions from code review
MorennMcFly Apr 17, 2025
8a17584
[POND-10]: add paragraphs and a slot in template, update docs
MorennMcFly Apr 17, 2025
649e0ef
[POND-10]: update docs
MorennMcFly Apr 17, 2025
9659066
Merge branch 'refs/heads/release/v0.2' into feature/POND-10-forgot-pa…
MorennMcFly May 8, 2025
1fb156d
[POND-10]: make email and password snippets more generic
MorennMcFly May 8, 2025
d8e26bd
Merge branch 'refs/heads/release/v0.2' into feature/POND-10-forgot-pa…
MorennMcFly May 16, 2025
ad10e7c
[POND-11]: fix indentation
MorennMcFly May 16, 2025
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
16 changes: 8 additions & 8 deletions components/account/AccountLoginInner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ const { t } = useI18n();
const schema = z.object({
username: z
.string({
required_error: t('account.login.email.error'),
required_error: t('account.email.error'),
})
.email(),

password: z
.string({
required_error: t('account.login.password.errorGeneral'),
required_error: t('account.password.errorGeneral'),
}),
});
export type LoginData = z.infer<typeof schema>;
Expand All @@ -43,26 +43,26 @@ const login = async (loginData: LoginData) => {
:schema="schema"
:field-config="{
username: {
label: $t('account.login.email.label'),
label: $t('account.email.label'),
inputProps: {
type: 'email',
placeholder: $t('account.login.email.placeholder'),
placeholder: $t('account.email.placeholder'),
},
},
password: {
label: $t('account.login.password.label'),
label: $t('account.password.label'),
inputProps: {
type: 'password',
placeholder: $t('account.login.password.placeholder'),
placeholder: $t('account.password.placeholder'),
},
},
}"
@submit="login"
>
<div class="!mt-0 grid">
<slot name="password-forgotten">
<NuxtLinkLocale to="/account/todo" class="mb-6 justify-self-start py-2 text-sm underline underline-offset-4">
{{ $t('account.login.password.forgotten') }}
<NuxtLinkLocale to="/account/recover" class="mb-6 justify-self-start py-2 text-sm underline underline-offset-4">
{{ $t('account.password.forgotten') }}
</NuxtLinkLocale>
</slot>

Expand Down
34 changes: 34 additions & 0 deletions components/account/AccountRecover.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { RecoverData } from './AccountRecoverInner.vue';

const isLoading = ref(false);
const showSuccessMessage = ref(false);

const customerStore = useCustomerStore();
const { getStorefrontUrl } = useInternationalization();

const recover = async (recoverData: RecoverData) => {
isLoading.value = true;

try {
await customerStore.resetPassword({
...recoverData,
storefrontUrl: getStorefrontUrl(),
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// catch but do nothing
} finally {
showSuccessMessage.value = true;
isLoading.value = false;
}
};
</script>

<template>
<AccountRecoverInner
:is-loading="isLoading"
:show-success-message="showSuccessMessage"
@recover="(recoverData: RecoverData) => recover(recoverData)"
/>
</template>
98 changes: 98 additions & 0 deletions components/account/AccountRecoverInner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
import * as z from 'zod';
const { t } = useI18n();

withDefaults(
defineProps<{
isLoading?: boolean;
showSuccessMessage?: boolean;
}>(),
{
isLoading: false,
showSuccessMessage: false,
},
);

const emits = defineEmits<{
recover: [recoverData: RecoverData];
}>();

const schema = z.object({
email: z
.string({
required_error: t('account.email.error'),
})
.email(),
});

export type RecoverData = z.infer<typeof schema>;

const recover = (recoverData: RecoverData) => {
emits('recover', recoverData);
};
</script>

<template>
<slot name="wrapper">
<div v-auto-animate>
<slot name="success-message">
<template v-if="showSuccessMessage">
<UiAlert variant="successful" class="mb-4 flex gap-4">
<slot name="alert-icon">
<Icon name="mdi:check" class="size-4 shrink-0" />
</slot>

<div>
<UiAlertTitle>{{ $t('account.recover.successHeader') }}</UiAlertTitle>
<UiAlertDescription>
{{ $t('account.recover.successMessage') }}
</UiAlertDescription>
</div>
</UiAlert>
</template>
</slot>

<slot name="header">
<h1 class="text-lg font-semibold">{{ $t('account.recover.header') }}</h1>
<hr>
</slot>

<slot name="info-text">
<p class="pb-4 pt-2 text-sm">{{ $t('account.recover.information') }}</p>
</slot>

<slot name="form">
<UiAutoForm
class="space-y-6"
:schema="schema"
:field-config="{
email: {
label: $t('account.email.label'),
inputProps: {
type: 'email',
placeholder: $t('account.email.placeholder'),
},
}
}"
@submit="recover"
>
<slot name="buttons">
<div class="flex flex-wrap gap-4">
<slot name="back-button">
<UiButton variant="outline" class="w-fit grow sm:grow-0">
<NuxtLinkLocale to="/account/login">{{ $t('account.recover.backButton') }}</NuxtLinkLocale>
</UiButton>
</slot>

<slot name="submit-button">
<UiButton type="submit" :is-loading="isLoading" class="min-w-52 grow">
{{ $t('account.recover.submitButton') }}
</UiButton>
</slot>
</div>
</slot>
</UiAutoForm>
</slot>
</div>
</slot>
</template>
Comment on lines +1 to +98
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider adding error handling for failed recovery attempts.

While the component handles success messages well, there's no visible mechanism to display errors if the recovery process fails. Consider adding error handling and displaying appropriate error messages.

You could add an additional prop like errorMessage and display it conditionally:

 <script setup lang="ts">
 import * as z from 'zod';
 const { t } = useI18n();
 
 withDefaults(
     defineProps<{
         isLoading?: boolean;
         showSuccessMessage?: boolean;
+        errorMessage?: string;
     }>(),
     {
         isLoading: false,
         showSuccessMessage: false,
+        errorMessage: '',
     },
 );

And in the template:

         <div v-auto-animate>
             <slot name="success-message">
                 <template v-if="showSuccessMessage">
                     <UiAlert variant="successful" class="mb-4 flex gap-4">
                         <!-- ... existing success message ... -->
                     </UiAlert>
                 </template>
             </slot>
 
+            <slot name="error-message">
+                <template v-if="errorMessage">
+                    <UiAlert variant="destructive" class="mb-4 flex gap-4">
+                        <slot name="error-alert-icon">
+                            <Icon name="mdi:alert-circle" class="size-4 shrink-0" />
+                        </slot>
+                        <div>
+                            <UiAlertTitle>{{ $t('account.recover.errorHeader') }}</UiAlertTitle>
+                            <UiAlertDescription>
+                                {{ errorMessage }}
+                            </UiAlertDescription>
+                        </div>
+                    </UiAlert>
+                </template>
+            </slot>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script setup lang="ts">
import * as z from 'zod';
const { t } = useI18n();
withDefaults(
defineProps<{
isLoading?: boolean;
showSuccessMessage?: boolean;
}>(),
{
isLoading: false,
showSuccessMessage: false,
},
);
const emits = defineEmits<{
recover: [recoverData: RecoverData];
}>();
const schema = z.object({
email: z
.string({
required_error: t('account.email.error'),
})
.email(),
});
export type RecoverData = z.infer<typeof schema>;
const recover = (recoverData: RecoverData) => {
emits('recover', recoverData);
};
</script>
<template>
<slot name="wrapper">
<div v-auto-animate>
<slot name="success-message">
<template v-if="showSuccessMessage">
<UiAlert variant="successful" class="mb-4 flex gap-4">
<slot name="alert-icon">
<Icon name="mdi:check" class="size-4 shrink-0" />
</slot>
<div>
<UiAlertTitle>{{ $t('account.recover.successHeader') }}</UiAlertTitle>
<UiAlertDescription>
{{ $t('account.recover.successMessage') }}
</UiAlertDescription>
</div>
</UiAlert>
</template>
</slot>
<slot name="header">
<h1 class="text-lg font-semibold">{{ $t('account.recover.header') }}</h1>
<hr>
</slot>
<slot name="info-text">
<p class="pb-4 pt-2 text-sm">{{ $t('account.recover.information') }}</p>
</slot>
<slot name="form">
<UiAutoForm
class="space-y-6"
:schema="schema"
:field-config="{
email: {
label: $t('account.email.label'),
inputProps: {
type: 'email',
placeholder: $t('account.email.placeholder'),
},
}
}"
@submit="recover"
>
<slot name="buttons">
<div class="flex flex-wrap gap-4">
<slot name="back-button">
<UiButton variant="outline" class="w-fit grow sm:grow-0">
<NuxtLinkLocale to="/account/login">{{ $t('account.recover.backButton') }}</NuxtLinkLocale>
</UiButton>
</slot>
<slot name="submit-button">
<UiButton type="submit" :is-loading="isLoading" class="min-w-52 grow">
{{ $t('account.recover.submitButton') }}
</UiButton>
</slot>
</div>
</slot>
</UiAutoForm>
</slot>
</div>
</slot>
</template>
<script setup lang="ts">
import * as z from 'zod';
const { t } = useI18n();
withDefaults(
defineProps<{
isLoading?: boolean;
showSuccessMessage?: boolean;
errorMessage?: string;
}>(),
{
isLoading: false,
showSuccessMessage: false,
errorMessage: '',
},
);
const emits = defineEmits<{
recover: [recoverData: RecoverData];
}>();
const schema = z.object({
email: z
.string({
required_error: t('account.email.error'),
})
.email(),
});
export type RecoverData = z.infer<typeof schema>;
const recover = (recoverData: RecoverData) => {
emits('recover', recoverData);
};
</script>
<template>
<slot name="wrapper">
<div v-auto-animate>
<slot name="success-message">
<template v-if="showSuccessMessage">
<UiAlert variant="successful" class="mb-4 flex gap-4">
<!-- ... existing success message ... -->
</UiAlert>
</template>
</slot>
<slot name="error-message">
<template v-if="errorMessage">
<UiAlert variant="destructive" class="mb-4 flex gap-4">
<slot name="error-alert-icon">
<Icon name="mdi:alert-circle" class="size-4 shrink-0" />
</slot>
<div>
<UiAlertTitle>{{ $t('account.recover.errorHeader') }}</UiAlertTitle>
<UiAlertDescription>
{{ errorMessage }}
</UiAlertDescription>
</div>
</UiAlert>
</template>
</slot>
<slot name="header">
<h1 class="text-lg font-semibold">{{ $t('account.recover.header') }}</h1>
<hr>
</slot>
<!-- rest of template unchanged -->
</div>
</slot>
</template>
🤖 Prompt for AI Agents
In components/account/AccountRecoverInner.vue lines 1 to 98, the component lacks
error handling for failed recovery attempts. Add a new optional prop called
errorMessage of type string to the defineProps with a default of an empty
string. Then, in the template, conditionally render an error alert component
(similar to the success message) that displays the errorMessage when it is not
empty. This will provide users with visible feedback when recovery fails.

1 change: 1 addition & 0 deletions components/ui/alert/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const alertVariants = cva(
variant: {
default: 'bg-white text-gray-950',
destructive: 'border-red-500/50 text-red-500',
successful: 'border-green-500/50 text-green-500',
},
},
defaultVariants: {
Expand Down
83 changes: 83 additions & 0 deletions docs/features/account-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Account management

## Account Recovery

The **Account Recovery** page provides users with a secure way to initiate a password reset via email. When a user submits an email address:

- If the email is **registered**, a recovery link is sent and a generic success message is displayed.
- If the email is **not registered**, a generic success message is still displayed to prevent account enumeration.

### Component Structure

This page is composed of layered components:

1. **`pages/account/recover.vue`** – wraps the recovery component in a responsive layout.

2. **`components/account/AccountRecover.vue`** – manages the logic, loading state, and message display.

3. **`components/account/AccountRecoverInner.vue`** – provides the form UI and emits the form submission event. Customizable via **named slots**.

---

### Named Slots

The `AccountRecoverInner.vue` component exposes several named slots for UI customization:

| Slot Name | Description |
|-------------------|--------------------------------------------------------------|
| `wrapper` | Wraps the component, including form and messages |
| `success-message` | Overrides the default success alert |
| `alert-icon` | Overrides the default success alert icon |
| `header` | Overrides the default heading |
| `info-text` | Overrides the informational text displayed below the heading |
| `form` | Wraps the form |
| `buttons` | Wraps the buttons |
| `back-button` | Overrides the back to login button |
| `submit-button` | Overrides the submit button |

---

### Props

`AccountRecoverInner.vue` accepts two optional props:

| Prop Name | Type | Description |
|----------------------|-----------|---------------------------------------------|
| `isLoading` | `boolean` | Controls a loading spinner on submit button |
| `showSuccessMessage` | `boolean` | Controls whether the success message shows |

---

### Events

| Event Name | Payload | Description |
|------------|-----------------------|----------------------------------|
| `recover` | `{ email: string }` | Emitted when form is submitted |

---

### Usage Example: Custom Header & Message

```vue
<AccountRecoverInner>
<template #header>
<h1 class="text-2xl font-bold text-primary">Reset Your Password</h1>
<p class="text-sm text-gray-500">Enter your email to receive a recovery link.</p>
<hr class="my-4" />
</template>

<template #success-message>
<UiAlert variant="successful" class="mb-4">
<Icon name="mdi:check" class="mr-2" />
<span>We've sent a link to your inbox—check your email!</span>
</UiAlert>
</template>
</AccountRecoverInner>
```

### Technical Notes

This component uses the `resetPassword` method from the `useCustomerPassword` composable under the hood.

You can learn more about the `useCustomerPassword` composable in the Shopware Frontends documentation: [Shopware Frontends – useCustomerPassword](https://frontends.shopware.com/packages/composables/useCustomerPassword.html)

34 changes: 20 additions & 14 deletions i18n/locales/de-DE/account.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@
"register": "Registrieren",
"logout": "Abmelden"
},
"login": {
"email": {
"label": "Ihre E-Mail-Adresse",
"placeholder": "E-Mail-Adresse",
"error": "Bitte geben Sie eine valide E-Mail-Adresse ein"
},
"password": {
"label": "Ihr Passwort",
"placeholder": "••••••••",
"forgotten": "Passwort vergessen",
"errorGeneral": "Bitte geben Sie ein Password ein",
"errorLength": "Ihr Passwort muss mind. {length} Zeichen lang sein"
}
"email": {
"label": "Ihre E-Mail-Adresse",
"placeholder": "E-Mail-Adresse",
"error": "Bitte geben Sie eine valide E-Mail-Adresse ein"
},
"password": {
"label": "Ihr Passwort",
"placeholder": "••••••••",
"forgotten": "Passwort vergessen",
"errorGeneral": "Bitte geben Sie ein Passwort ein",
"errorLength": "Ihr Passwort muss mind. {length} Zeichen lang sein"
},
"recover": {
"header": "Passwort-Wiederherstellung",
"information": "Wir senden Ihnen eine Bestätigungs-E-Mail. Klicken Sie auf den darin enthaltenen Link, um Ihr Passwort zu ändern.",
"submitButton": "E-Mail anfordern",
"backButton": "Zurück zum Login",
"successHeader": "E-Mail gesendet",
"successMessage": "Eine Bestätigungs-E-Mail mit einem Link zum Zurücksetzen des Passworts wurde versandt, wenn die angegebene E-Mail-Adresse registriert ist."
},
"customer": {
"accountType": "Accounttyp",
Expand Down Expand Up @@ -87,4 +93,4 @@
}
}
}
}
}
32 changes: 19 additions & 13 deletions i18n/locales/en-GB/account.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,25 @@
"register": "Sign up",
"logout": "Logout"
},
"login": {
"email": {
"label": "Your Email Address",
"placeholder": "Email address",
"error": "Please enter a valid email address"
},
"password": {
"label": "Your Password",
"placeholder": "••••••••",
"forgotten": "Password forgotten",
"errorGeneral": "Please enter a password",
"errorLength": "Your password must be at least {length} characters long"
}
"email": {
"label": "Your Email Address",
"placeholder": "Email address",
"error": "Please enter a valid email address"
},
"password": {
"label": "Your Password",
"placeholder": "••••••••",
"forgotten": "Password forgotten",
"errorGeneral": "Please enter a password",
"errorLength": "Your password must be at least {length} characters long"
},
"recover": {
"header": "Password recovery",
"information": "We will send you a confirmation email. Click the link in that email in order to change your password.",
"submitButton": "Request email",
"backButton": "Back to login",
"successHeader": "Email sent",
"successMessage": "A confirmation email with a password reset link has been sent if the email address provided is registered."
},
"customer": {
"accountType": "Account type",
Expand Down
7 changes: 7 additions & 0 deletions pages/account/recover.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<template>
<div class="container relative flex justify-center py-4">
<div class="w-full md:w-2/3 xl:w-1/2">
<AccountRecover />
</div>
</div>
</template>
Loading