Skip to content

Commit a007062

Browse files
committed
Updated Account Form
1 parent 5f5a265 commit a007062

File tree

9 files changed

+175
-50
lines changed

9 files changed

+175
-50
lines changed

src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ProblemDetails } from '@exceptionless/fetchclient';
22
import { validate as classValidate } from 'class-validator';
3-
import { type FormPathLeavesWithErrors, setError, setMessage, type SuperValidated } from 'sveltekit-superforms';
3+
import { type ErrorStatus, type FormPathLeavesWithErrors, setError, setMessage, type SuperValidated } from 'sveltekit-superforms';
44

55
export async function validate(data: null | object): Promise<null | ProblemDetails> {
66
if (data === null) {
@@ -27,12 +27,12 @@ export function applyServerSideErrors<T extends Record<string, unknown> = Record
2727
problem: null | ProblemDetails
2828
) {
2929
if (!problem || problem.status !== 422) {
30-
setMessage(form, 'An error occurred. Please try again.' as M);
30+
setMessage(form, problem?.title as M, { status: (problem?.status as ErrorStatus) ?? 500 });
3131
return;
3232
}
3333

3434
for (const key in problem.errors) {
3535
const errors = problem.errors[key] as string[];
36-
setError(form, key as FormPathLeavesWithErrors<T>, errors);
36+
setError(form, key as FormPathLeavesWithErrors<T>, errors, { status: (problem?.status as ErrorStatus) ?? 500 });
3737
}
3838
}

src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { accessToken } from '$features/auth/index.svelte';
22
import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
33
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
44

5-
import { type UpdateUser, User } from './models';
5+
import { UpdateEmailAddressResult, type UpdateUser, User } from './models';
66

77
export const queryKeys = {
88
all: ['User'] as const,
99
id: (id: string | undefined) => [...queryKeys.all, id] as const,
10+
idEmailAddress: (id?: string) => [...queryKeys.id(id), 'email-address'] as const,
1011
me: () => [...queryKeys.all, 'me'] as const
1112
};
1213

@@ -30,6 +31,35 @@ export function getMeQuery() {
3031
queryKey: queryKeys.me()
3132
}));
3233
}
34+
export interface UpdateEmailAddressProps {
35+
id: string | undefined;
36+
}
37+
38+
export function mutateEmailAddress(props: UpdateEmailAddressProps) {
39+
const queryClient = useQueryClient();
40+
return createMutation<UpdateEmailAddressResult, ProblemDetails, Pick<User, 'email_address'>>(() => ({
41+
enabled: () => !!accessToken.value && !!props.id,
42+
mutationFn: async (data: Pick<User, 'email_address'>) => {
43+
const client = useFetchClient();
44+
const response = await client.postJSON<UpdateEmailAddressResult>(`users/${props.id}/email-address/${data.email_address}`);
45+
return response.data!;
46+
},
47+
mutationKey: queryKeys.idEmailAddress(props.id),
48+
onSuccess: (data, variables) => {
49+
const partialUserData: User = { email_address: variables.email_address, is_email_address_verified: data.is_verified };
50+
51+
const user = queryClient.getQueryData<User>(queryKeys.id(props.id));
52+
if (user) {
53+
queryClient.setQueryData(queryKeys.id(props.id), <User>{ ...user, ...partialUserData });
54+
}
55+
56+
const currentUser = queryClient.getQueryData<User>(queryKeys.me());
57+
if (currentUser?.id === props.id) {
58+
queryClient.setQueryData(queryKeys.me(), <User>{ ...currentUser, ...partialUserData });
59+
}
60+
}
61+
}));
62+
}
3363

3464
export interface UpdateUserProps {
3565
id: string | undefined;

src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { IsOptional } from 'class-validator';
22

3-
export { User } from '$generated/api';
3+
export { UpdateEmailAddressResult, User } from '$generated/api';
44

55
export class UpdateUser {
66
@IsOptional() email_notifications_enabled?: boolean;
Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,64 @@
11
<script lang="ts">
22
import ErrorMessage from '$comp/ErrorMessage.svelte';
33
import Loading from '$comp/Loading.svelte';
4-
import { H3, Muted } from '$comp/typography';
4+
import { A, H3, Muted, Small } from '$comp/typography';
55
import * as Avatar from '$comp/ui/avatar';
66
import * as Form from '$comp/ui/form';
77
import { Input } from '$comp/ui/input';
88
import { Separator } from '$comp/ui/separator';
99
import { applyServerSideErrors } from '$features/shared/validation';
10-
import { getMeQuery, mutateUser } from '$features/users/api.svelte';
10+
import { getMeQuery, mutateEmailAddress, mutateUser } from '$features/users/api.svelte';
1111
import { getGravatarFromCurrentUser } from '$features/users/gravatar.svelte';
12-
import { UpdateUser } from '$features/users/models';
13-
import { ProblemDetails } from "@exceptionless/fetchclient";
12+
import { UpdateUser, User } from '$features/users/models';
13+
import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
1414
import { toast } from 'svelte-sonner';
1515
import { defaults, superForm } from 'sveltekit-superforms';
16-
import SuperDebug from 'sveltekit-superforms';
1716
import { classvalidatorClient } from 'sveltekit-superforms/adapters';
1817
import { debounce } from 'throttle-debounce';
1918
20-
let toastId = $state<number|string>();
19+
let toastId = $state<number | string>();
2120
const userResponse = getMeQuery();
21+
const isEmailAddressVerified = $derived(userResponse.data?.is_email_address_verified ?? false);
2222
const gravatar = getGravatarFromCurrentUser(userResponse);
2323
const updateUser = mutateUser({
2424
get id() {
2525
return userResponse.data?.id;
2626
}
2727
});
28+
const updateEmailAddress = mutateEmailAddress({
29+
get id() {
30+
return userResponse.data?.id;
31+
}
32+
});
33+
34+
const updateEmailAddressForm = superForm(defaults(userResponse.data ?? {}, classvalidatorClient(User)), {
35+
id: 'update-email-address',
36+
async onUpdate({ form, result }) {
37+
if (!form.valid) {
38+
return;
39+
}
2840
29-
const form = superForm(defaults(userResponse.data ?? {}, classvalidatorClient(UpdateUser)), {
30-
onError() {
31-
toastId = toast.error('An error occurred while saving your full name.');
41+
toast.dismiss(toastId);
42+
try {
43+
await updateEmailAddress.mutateAsync(form.data);
44+
toastId = toast.success('Account updated successfully.');
45+
46+
// HACK: This is to prevent sveltekit from stealing focus
47+
result.type = 'failure';
48+
} catch (error: unknown) {
49+
if (error instanceof ProblemDetails) {
50+
applyServerSideErrors(form, error);
51+
result.status = error.status ?? 500;
52+
toastId = toast.error(form.message ?? 'Error saving email address. Please try again.');
53+
}
54+
}
3255
},
56+
SPA: true,
57+
validators: classvalidatorClient(User)
58+
});
59+
60+
const updateUserForm = superForm(defaults(userResponse.data ?? {}, classvalidatorClient(UpdateUser)), {
61+
id: 'update-user',
3362
async onUpdate({ form, result }) {
3463
if (!form.valid) {
3564
return;
@@ -39,27 +68,65 @@
3968
try {
4069
form.data = await updateUser.mutateAsync(form.data);
4170
toastId = toast.success('Account updated successfully.');
71+
72+
// HACK: This is to prevent sveltekit from stealing focus
73+
result.type = 'failure';
4274
} catch (error: unknown) {
4375
if (error instanceof ProblemDetails) {
44-
result.status = error.status ?? 500;
4576
applyServerSideErrors(form, error);
77+
result.status = error.status ?? 500;
78+
toastId = toast.error(form.message ?? 'Error saving full name. Please try again.');
4679
}
47-
48-
throw error;
4980
}
5081
},
5182
SPA: true,
5283
validators: classvalidatorClient(UpdateUser)
5384
});
5485
5586
$effect(() => {
56-
if (userResponse.isSuccess && !$submitting) {
57-
form.reset({ data: userResponse.data });
87+
if (!userResponse.isSuccess) {
88+
return;
89+
}
90+
91+
if (!$updateEmailAddressFormSubmitting && !$updateEmailAddressFormTainted) {
92+
updateEmailAddressForm.reset({ data: userResponse.data, keepMessage: true });
93+
}
94+
95+
if (!$updateUserFormSubmitting && !$updateUserFormTainted) {
96+
updateUserForm.reset({ data: userResponse.data, keepMessage: true });
5897
}
5998
});
6099
61-
const { enhance, form: formData, message, submit, submitting, tainted } = form;
62-
const debouncedSubmit = debounce(1000, submit);
100+
const {
101+
enhance: updateEmailAddressFormEnhance,
102+
form: updateEmailAddressFormData,
103+
message: updateEmailAddressFormMessage,
104+
submit: updateEmailAddressFormSubmit,
105+
submitting: updateEmailAddressFormSubmitting,
106+
tainted: updateEmailAddressFormTainted
107+
} = updateEmailAddressForm;
108+
const debouncedUpdateEmailAddressFormSubmit = debounce(1000, updateEmailAddressFormSubmit);
109+
110+
const {
111+
enhance: updateUserFormEnhance,
112+
form: updateUserFormData,
113+
message: updateUserFormMessage,
114+
submit: updateUserFormSubmit,
115+
submitting: updateUserFormSubmitting,
116+
tainted: updateUserFormTainted
117+
} = updateUserForm;
118+
const debouncedUpdatedUserFormSubmit = debounce(1000, updateUserFormSubmit);
119+
120+
async function resendVerificationEmail() {
121+
toast.dismiss(toastId);
122+
const client = useFetchClient();
123+
try {
124+
await client.get(`users/${userResponse.data?.id}/resend-verification-email`);
125+
toastId = toast.success('Please check your inbox for the verification email.');
126+
} catch {
127+
toastId = toast.error('Error sending verification email. Please try again.');
128+
}
129+
}
63130
</script>
64131

65132
<div class="space-y-6">
@@ -79,32 +146,45 @@
79146
</Avatar.Root>
80147
<Muted>Your avatar is generated by requesting a Gravatar image with the email address below.</Muted>
81148

82-
<SuperDebug data={$tainted} />
83-
<SuperDebug data={$formData} />
84-
<form use:enhance>
85-
<ErrorMessage message={$message}></ErrorMessage>
86-
<Form.Field {form} name="full_name">
149+
<form use:updateUserFormEnhance>
150+
<Form.Field form={updateUserForm} name="full_name">
87151
<Form.Control let:attrs>
88152
<Form.Label>Full Name</Form.Label>
89-
<Input {...attrs} bind:value={$formData.full_name} placeholder="Full Name" autocomplete="name" required oninput={debouncedSubmit} />
153+
<Input
154+
{...attrs}
155+
bind:value={$updateUserFormData.full_name}
156+
placeholder="Full Name"
157+
autocomplete="name"
158+
required
159+
oninput={debouncedUpdatedUserFormSubmit}
160+
/>
90161
</Form.Control>
91162
<Form.Description />
92163
<Form.FieldErrors />
164+
<ErrorMessage message={$updateUserFormMessage}></ErrorMessage>
93165
</Form.Field>
94-
<!-- <Form.Field {form} name="email_address">
166+
</form>
167+
<form use:updateEmailAddressFormEnhance>
168+
<Form.Field form={updateEmailAddressForm} name="email_address">
95169
<Form.Control let:attrs>
96170
<Form.Label>Email</Form.Label>
97-
<Input {...attrs} bind:value={$formData.email_address} placeholder="Enter email address" autocomplete="email" required />
171+
<Input
172+
{...attrs}
173+
bind:value={$updateEmailAddressFormData.email_address}
174+
placeholder="Enter email address"
175+
autocomplete="email"
176+
required
177+
oninput={debouncedUpdateEmailAddressFormSubmit}
178+
/>
98179
</Form.Control>
99180
<Form.Description />
100181
<Form.FieldErrors />
101-
</Form.Field> -->
102-
<!-- <Form.Button>
103-
{#if $submitting}
104-
<Loading class="mr-2" variant="secondary"></Loading> Saving...
105-
{:else}
106-
Save
107-
{/if}
108-
</Form.Button> -->
182+
<ErrorMessage message={$updateEmailAddressFormMessage}></ErrorMessage>
183+
</Form.Field>
109184
</form>
185+
{#if !isEmailAddressVerified}
186+
<Small>
187+
Email not verified. <A class="cursor-pointer" onclick={resendVerificationEmail}>Resend</A> verification email.
188+
</Small>
189+
{/if}
110190
</div>

src/Exceptionless.Web/ClientApp/src/routes/(auth)/login/+page.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
const defaultFormData = new Login();
3535
defaultFormData.invite_token = $page.url.searchParams.get('token');
3636
const form = superForm(defaults(defaultFormData, classvalidatorClient(Login)), {
37-
async onUpdate({ form }) {
37+
async onUpdate({ form, result }) {
3838
if (!form.valid) {
3939
return;
4040
}
@@ -44,6 +44,7 @@
4444
await goto(redirectUrl);
4545
} else {
4646
applyServerSideErrors(form, response.problem);
47+
result.status = response.problem.status ?? 500;
4748
}
4849
},
4950
SPA: true,

src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,30 @@ protected ObjectResult BadRequest(ModelActionResults results)
196196
{
197197
return StatusCode(StatusCodes.Status400BadRequest, results);
198198
}
199+
199200
protected StatusCodeResult Forbidden()
200201
{
201202
return StatusCode(StatusCodes.Status403Forbidden);
202203
}
203204

204205
protected ObjectResult Forbidden(string message)
205206
{
206-
return StatusCode(StatusCodes.Status403Forbidden, new MessageContent(message));
207+
return Problem(statusCode: StatusCodes.Status403Forbidden, title: message);
207208
}
208209

209210
protected ObjectResult PlanLimitReached(string message)
210211
{
211-
return StatusCode(StatusCodes.Status426UpgradeRequired, new MessageContent(message));
212+
return Problem(statusCode: StatusCodes.Status426UpgradeRequired, title: message);
213+
}
214+
215+
protected ObjectResult TooManyRequests(string message)
216+
{
217+
return Problem(statusCode: StatusCodes.Status429TooManyRequests, title: message);
212218
}
213219

214220
protected ObjectResult NotImplemented(string message)
215221
{
216-
return StatusCode(StatusCodes.Status501NotImplemented, new MessageContent(message));
222+
return Problem(statusCode: StatusCodes.Status501NotImplemented, title: message);
217223
}
218224

219225
protected OkWithHeadersContentResult<T> OkWithLinks<T>(T content, string link)

src/Exceptionless.Web/Controllers/UserController.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Foundatio.Repositories;
1515
using Microsoft.AspNetCore.Authorization;
1616
using Microsoft.AspNetCore.Mvc;
17+
using Microsoft.AspNetCore.Mvc.ModelBinding;
1718

1819
namespace Exceptionless.Web.Controllers;
1920

@@ -136,7 +137,7 @@ public Task<ActionResult<WorkInProgressResult>> DeleteCurrentUserAsync()
136137
/// <summary>
137138
/// Remove
138139
/// </summary>
139-
/// <param name="ids">A comma delimited list of user identifiers.</param>
140+
/// <param name="ids">A comma-delimited list of user identifiers.</param>
140141
/// <response code="204">No Content.</response>
141142
/// <response code="400">One or more validation errors occurred.</response>
142143
/// <response code="404">One or more users were not found.</response>
@@ -155,7 +156,8 @@ public Task<ActionResult<WorkInProgressResult>> DeleteAsync(string ids)
155156
/// <param name="id">The identifier of the user.</param>
156157
/// <param name="email">The new email address.</param>
157158
/// <response code="400">An error occurred while updating the users email address.</response>
158-
/// <response code="404">The user could not be found.</response>
159+
/// <response code="422">Validation error</response>
160+
/// <response code="429">Update email address rate limit reached.</response>
159161
[HttpPost("{id:objectid}/email-address/{email:minlength(1)}")]
160162
public async Task<ActionResult<UpdateEmailAddressResult>> UpdateEmailAddressAsync(string id, string email)
161163
{
@@ -173,10 +175,13 @@ public async Task<ActionResult<UpdateEmailAddressResult>> UpdateEmailAddressAsyn
173175
string updateEmailAddressAttemptsCacheKey = $"{CurrentUser.Id}:attempts";
174176
long attempts = await _cache.IncrementAsync(updateEmailAddressAttemptsCacheKey, 1, _timeProvider.GetUtcNow().UtcDateTime.Ceiling(TimeSpan.FromHours(1)));
175177
if (attempts > 3)
176-
return BadRequest("Update email address rate limit reached. Please try updating later.");
178+
return TooManyRequests("Unable to update email address. Please try later.");
177179

178180
if (!await IsEmailAddressAvailableInternalAsync(email))
179-
return BadRequest("A user with this email address already exists.");
181+
{
182+
ModelState.AddModelError<User>(m => m.EmailAddress, "A user already exists with this email address.");
183+
return ValidationProblem(ModelState);
184+
}
180185

181186
user.ResetPasswordResetToken();
182187
user.EmailAddress = email;
@@ -199,7 +204,7 @@ public async Task<ActionResult<UpdateEmailAddressResult>> UpdateEmailAddressAsyn
199204
if (!user.IsEmailAddressVerified)
200205
await ResendVerificationEmailAsync(id);
201206

202-
// TODO: We may want to send an email to old email addresses as well.
207+
// TODO: We may want to send email to old email addresses as well.
203208
return Ok(new UpdateEmailAddressResult { IsVerified = user.IsEmailAddressVerified });
204209
}
205210

0 commit comments

Comments
 (0)