From 5821f7c688321cf02483bd0ffafd04368032454b Mon Sep 17 00:00:00 2001
From: Gregor Vostrak
Date: Fri, 29 May 2026 15:31:33 +0200
Subject: [PATCH] add pending email cancel button
---
e2e/profile.spec.ts | 37 ++++++++++++++++
.../Partials/UpdateProfileInformationForm.vue | 43 +++++++++++++++----
.../packages/api/src/openapi.json.client.ts | 32 ++++++++++++++
resources/js/utils/useUserQuery.ts | 18 ++++++++
4 files changed, 121 insertions(+), 9 deletions(-)
diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts
index f71782f0..bcc115db 100644
--- a/e2e/profile.spec.ts
+++ b/e2e/profile.spec.ts
@@ -81,6 +81,7 @@ test('profile photo can be uploaded, persists across reload, and can be removed'
reloadedForm.getByRole('button', { name: 'Remove Photo' }).click(),
]);
await expect(reloadedProfilePhoto).toHaveAttribute('src', /ui-avatars\.com/);
+ await expect(reloadedForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
await page.reload();
const finalForm = profileInformationForm(page);
@@ -88,6 +89,7 @@ test('profile photo can be uploaded, persists across reload, and can be removed'
'src',
/ui-avatars\.com/
);
+ await expect(finalForm.getByRole('button', { name: 'Remove Photo' })).toBeHidden();
});
test('field-level validation errors render inline when the server returns 422', async ({
@@ -206,6 +208,41 @@ test('clicking resend sends a second verification email and shows confirmation',
expect(afterCount).toBeGreaterThan(beforeCount);
});
+test('cancelling a pending email change clears it and hides the banner', async ({ page, ctx }) => {
+ const { email: currentEmail } = await getCurrentUserViaApi(ctx);
+ const newEmail = `cancel+${Date.now()}@test.com`;
+
+ await goToProfilePage(page);
+ await page.getByLabel('Email').fill(newEmail);
+ await saveProfileForm(page);
+
+ // The pending-email banner is shown with the cancel control.
+ await expect(page.getByText('A verification link was sent to')).toBeVisible();
+ await expect(page.getByText(newEmail)).toBeVisible();
+ const cancelButton = page.getByRole('button', { name: 'Cancel email change' });
+ await expect(cancelButton).toBeVisible();
+
+ // Cancelling clears the pending email server-side (204).
+ await Promise.all([
+ page.waitForResponse(
+ (response) =>
+ response.url().includes('/reset-pending-email') &&
+ response.request().method() === 'POST' &&
+ response.status() === 204
+ ),
+ cancelButton.click(),
+ ]);
+
+ // The banner disappears and the email field still shows the current address.
+ await expect(page.getByText('A verification link was sent to')).toBeHidden();
+ await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
+
+ // The cancellation is persistent — still gone after a reload.
+ await page.reload();
+ await expect(page.getByText('A verification link was sent to')).toBeHidden();
+ await expect(page.getByLabel('Email')).toHaveValue(currentEmail);
+});
+
test('re-submitting the same pending email does not send another verification email', async ({
page,
request,
diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue
index 1917ea03..6afcfe13 100644
--- a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue
+++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue
@@ -5,11 +5,13 @@ import axios from 'axios';
import ActionMessage from '@/Components/ActionMessage.vue';
import FormSection from '@/Components/FormSection.vue';
import { Field, FieldError, FieldLabel } from '@/packages/ui/src/field';
+import { Button } from '@/packages/ui/src/Buttons';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import {
useResendUserEmailVerificationMutation,
+ useResetUserPendingEmailMutation,
useUpdateUserMutation,
useUserQuery,
} from '@/utils/useUserQuery';
@@ -18,6 +20,7 @@ import type { UpdateUserBody, User } from '@/packages/api/src';
const { user } = useUserQuery();
const updateUser = useUpdateUserMutation();
const resendVerification = useResendUserEmailVerificationMutation();
+const resetPendingEmail = useResetUserPendingEmailMutation();
const name = ref('');
const email = ref('');
@@ -152,6 +155,17 @@ async function clickResend() {
}
}
+async function clickCancelEmailChange() {
+ if (!user.value || resetPendingEmail.isPending.value) return;
+ try {
+ // Clears pending_email on the server; the pending banner hides once the
+ // me query refetches. The email field already shows the current address.
+ await resetPendingEmail.mutateAsync(user.value.id);
+ } catch {
+ // notification handled by mutation
+ }
+}
+
function flashSaved() {
recentlySaved.value = true;
setTimeout(() => (recentlySaved.value = false), 2000);
@@ -259,15 +273,26 @@ const page = usePage<{
{{ pendingEmail }}. Click the link in the email to confirm the change.
-
- Verification email sent.
+
+
+
Verification email sent.
+
+
diff --git a/resources/js/packages/api/src/openapi.json.client.ts b/resources/js/packages/api/src/openapi.json.client.ts
index cb6b7db7..d260e1fb 100644
--- a/resources/js/packages/api/src/openapi.json.client.ts
+++ b/resources/js/packages/api/src/openapi.json.client.ts
@@ -4525,6 +4525,38 @@ The report is considered public if the `is_public` field is set to
},
],
},
+ {
+ method: 'post',
+ path: '/v1/users/:user/reset-pending-email',
+ alias: 'resetUserPendingEmail',
+ description: `This endpoint is independent of the organization.`,
+ requestFormat: 'json',
+ parameters: [
+ {
+ name: 'user',
+ type: 'Path',
+ schema: z.string(),
+ },
+ ],
+ response: z.void(),
+ errors: [
+ {
+ status: 401,
+ description: `Unauthenticated`,
+ schema: z.object({ message: z.string() }).passthrough(),
+ },
+ {
+ status: 403,
+ description: `Authorization error`,
+ schema: z.object({ message: z.string() }).passthrough(),
+ },
+ {
+ status: 404,
+ description: `Not found`,
+ schema: z.object({ message: z.string() }).passthrough(),
+ },
+ ],
+ },
{
method: 'post',
path: '/v1/users/:user/resend-email-verification',
diff --git a/resources/js/utils/useUserQuery.ts b/resources/js/utils/useUserQuery.ts
index fda78d92..6757fa18 100644
--- a/resources/js/utils/useUserQuery.ts
+++ b/resources/js/utils/useUserQuery.ts
@@ -93,3 +93,21 @@ export function useResendUserEmailVerificationMutation() {
},
});
}
+
+export function useResetUserPendingEmailMutation() {
+ const queryClient = useQueryClient();
+ const { handleApiRequestNotifications } = useNotificationsStore();
+
+ return useMutation({
+ mutationFn: async (userId: string) => {
+ return handleApiRequestNotifications(
+ () => api.resetUserPendingEmail(undefined, { params: { user: userId } }),
+ 'Email change canceled',
+ 'Failed to cancel email change'
+ );
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
+ },
+ });
+}