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 581ffe4a..3800fbf5 100644 --- a/resources/js/packages/api/src/openapi.json.client.ts +++ b/resources/js/packages/api/src/openapi.json.client.ts @@ -4573,6 +4573,38 @@ The report is considered public if the `is_public` field is set to &#x }, ], }, + { + 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 }); + }, + }); +}