Fixing the settings change email flow.

This commit is contained in:
Bradley Shellnut 2025-01-05 22:27:41 -08:00
parent 224380d9e2
commit fed1bfb524
12 changed files with 67 additions and 60 deletions

8
.run/AdelieStack.xml Normal file
View file

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="AdelieStack" type="NodeJSConfigurationType" path-to-js-file="node_modules/vite/bin/vite.js" typescript-loader="bundled" working-dir="$PROJECT_DIR$">
<envs>
<env name="NODE_ENV" value="development" />
</envs>
<method v="2" />
</configuration>
</component>

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
export const verifyEmailDto = z.object({
token: z.string()
code: z.string().length(6),
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

View file

@ -13,7 +13,9 @@ type UnauthedReturnType = typeof unauthed;
export function authState(state: 'session'): AuthedReturnType;
export function authState(state: 'none'): UnauthedReturnType;
export function authState(state: AuthStates): AuthedReturnType | UnauthedReturnType {
if (state === 'session') return authed;
if (state === 'session') {
return authed;
}
return unauthed;
}

View file

@ -30,6 +30,7 @@ export class IamController extends Controller {
routes() {
return this.controller
.post('/login', openApi(signInEmail), authState('none'), zValidator('json', signinDto), rateLimit({ limit: 3, minutes: 1 }), async (c) => {
this.loggerService.log.info(`Login with identifier: ${c.req.valid('json').identifier}`);
const session = await this.loginRequestsService.login(c.req.valid('json'));
await this.sessionsService.setSessionCookie(session);
return c.json({ message: 'welcome' });

View file

@ -1,6 +0,0 @@
import { z } from 'zod';
import { userDto } from './user.dto';
export const updateEmailDto = userDto.pick({ email: true });
export type UpdateEmailDto = z.infer<typeof updateEmailDto>;

View file

@ -1,7 +0,0 @@
import { z } from 'zod';
export const verifyEmailDto = z.object({
code: z.string().length(6)
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

View file

@ -6,11 +6,13 @@ import { EmailChangeNoticeEmail } from '../../mail/templates/email-change-notice
import { BadRequest } from '../../common/utils/exceptions';
import { UsersRepository } from '../users.repository';
import { VerificationCodesService } from '../../common/services/verification-codes.service';
import { LoggerService } from '../../common/services/logger.service';
@injectable()
export class EmailChangeRequestsService {
constructor(
private emailChangeRequetsRepository = inject(EmailChangeRequestsRepository),
private loggerService = inject(LoggerService),
private verificationCodesService = inject(VerificationCodesService),
private mailerService = inject(MailerService),
private usersRepository = inject(UsersRepository)
@ -44,19 +46,24 @@ export class EmailChangeRequestsService {
to: requestedEmail,
template: new EmailChangeRequestEmail(verificationCode)
});
this.loggerService.log.info(`Email change request sent to ${requestedEmail}`);
}
async verifyEmailChange(userId: string, verificationCode: string) {
// Get the email change request
const emailChangeRequest = await this.emailChangeRequetsRepository.get(userId);
if (!emailChangeRequest) throw BadRequest('Bad Request');
if (!emailChangeRequest) {
throw BadRequest('Bad Request');
}
// Verify the verification code
const isValid = await this.verificationCodesService.verify({
verificationCode,
hashedVerificationCode: emailChangeRequest.hashedCode
});
if (!isValid) throw BadRequest('Bad Request');
if (!isValid) {
throw BadRequest('Bad Request');
}
// Update the account's email
await this.usersRepository.update(userId, {

View file

@ -5,9 +5,9 @@ import { authState } from '../common/middleware/auth.middleware';
import { zValidator } from '@hono/zod-validator';
import { updateUserDto } from './dtos/update-user.dto';
import { EmailChangeRequestsService } from './email-change-requests/email-change-requests.service';
import { updateEmailDto } from './dtos/update-email.dto';
import { updateEmailDto } from '$lib/dtos/settings/update-email.dto';
import { UsersRepository } from './users.repository';
import { verifyEmailDto } from './dtos/verify-email.dto';
import { verifyEmailDto } from '$lib/dtos/settings/verify-email.dto';
import { rateLimit } from '../common/middleware/rate-limit.middleware';
@injectable()
@ -39,6 +39,7 @@ export class UsersController extends Controller {
zValidator('json', updateEmailDto),
rateLimit({ limit: 5, minutes: 15 }),
async (c) => {
c.var.logger.info(`Request email change: ${c.req.valid('json').email}`);
await this.emailChangeRequestsService.requestEmailChange(
c.var.session.userId,
c.req.valid('json').email

View file

@ -1,32 +1,47 @@
import { zod } from "sveltekit-superforms/adapters";
import { fail, setError, superValidate } from "sveltekit-superforms";
import { StatusCodes } from "@/constants/status-codes.js";
import { updateEmailFormSchema, verifyEmailFormSchema } from "./schemas.js";
import { updateEmailDto } from "$lib/dtos/settings/update-email.dto.js";
import { verifyEmailDto } from "$lib/dtos/settings/verify-email.dto.js";
import { redirect } from "sveltekit-flash-message/server";
import { notSignedInMessage } from "$lib/utils/flashMessages.js";
export let load = async (event) => {
const authedUser = await event.locals.getAuthedUserOrThrow()
export const load = async (event) => {
const { parent } = event;
const { authedUser } = await parent();
if (!authedUser) {
throw redirect('/login', notSignedInMessage, event);
}
return {
authedUser,
updateEmailForm: await superValidate(authedUser, zod(updateEmailFormSchema)),
verifyEmailForm: await superValidate(zod(verifyEmailFormSchema))
updateEmailForm: await superValidate(zod(updateEmailDto), {
defaults: {
email: authedUser.email
}
}),
verifyEmailForm: await superValidate(zod(verifyEmailDto))
};
};
export const actions = {
updateEmail: async ({ request, locals }) => {
const updateEmailForm = await superValidate(request, zod(updateEmailFormSchema));
const updateEmailForm = await superValidate(request, zod(updateEmailDto));
if (!updateEmailForm.valid) return fail(StatusCodes.BAD_REQUEST, { updateEmailForm })
const { error } = await locals.api.iam.email.$patch({ json: updateEmailForm.data }).then(locals.parseApiResponse);
const { error } = await locals.api.users.me.email.request.$post({ json: updateEmailForm.data }).then(locals.parseApiResponse);
if (error) return setError(updateEmailForm, 'email', error);
return { updateEmailForm }
},
verifyEmail: async ({ request, locals }) => {
const verifyEmailForm = await superValidate(request, zod(verifyEmailFormSchema));
const verifyEmailForm = await superValidate(request, zod(verifyEmailDto));
console.log(verifyEmailForm)
if (!verifyEmailForm.valid) return fail(StatusCodes.BAD_REQUEST, { verifyEmailForm })
const { error } = await locals.api.iam.email.verify.$post({ json: verifyEmailForm.data }).then(locals.parseApiResponse);
if (error) return setError(verifyEmailForm, 'token', error);
if (!verifyEmailForm.valid) {
return fail(StatusCodes.BAD_REQUEST, { verifyEmailForm });
}
const { error } = await locals.api.users.me.email.verify.$post({ json: verifyEmailForm.data }).then(locals.parseApiResponse);
if (error) {
return setError(verifyEmailForm, 'code', error);
}
return { verifyEmailForm }
}
};

View file

@ -1,9 +0,0 @@
import { z } from "zod";
export const verifyEmailFormSchema = z.object({
token: z.string()
});
export const updateEmailFormSchema = z.object({
email: z.string().email()
});

View file

@ -1,12 +1,3 @@
<script module lang="ts">
import type { Infer, SuperValidated } from 'sveltekit-superforms';
interface UpdateEmailCardProps {
updateEmailForm: SuperValidated<Infer<typeof updateEmailDto>>;
verifyEmailForm: SuperValidated<Infer<typeof verifyEmailDto>>;
}
</script>
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
@ -15,28 +6,32 @@ interface UpdateEmailCardProps {
import { superForm } from 'sveltekit-superforms';
import * as Dialog from '@/components/ui/dialog';
import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import type { updateEmailDto } from '$lib/dtos/update-email.dto';
import type { verifyEmailDto } from '$lib/dtos/verify-email.dto';
import type { UpdateEmailDto } from '$lib/dtos/settings/update-email.dto';
import type { VerifyEmailDto } from '$lib/dtos/settings/verify-email.dto';
/* ---------------------------------- props --------------------------------- */
let { updateEmailForm, verifyEmailForm }: UpdateEmailCardProps = $props();
let { updateEmailForm, verifyEmailForm }: { updateEmailForm: UpdateEmailDto; verifyEmailForm: VerifyEmailDto } = $props();
/* ---------------------------------- state --------------------------------- */
let verifyTokenDialogOpen = $state(false);
let verifyDialogOpen = $state(false);
/* ---------------------------------- forms --------------------------------- */
const sf_updateEmailForm = superForm(updateEmailForm, {
resetForm: false,
onUpdated: ({ form }) => {
if (!form.valid) return;
verifyTokenDialogOpen = true;
if (!form.valid) {
return;
}
verifyDialogOpen = true;
}
});
const sf_verifyEmailForm = superForm(verifyEmailForm, {
onUpdated: ({ form }) => {
if (!form.valid) return;
verifyTokenDialogOpen = false;
if (!form.valid) {
return;
}
verifyDialogOpen = false;
}
});
@ -80,7 +75,7 @@ interface UpdateEmailCardProps {
</Card.Root>
<!-- Dialogs -->
<Dialog.Root bind:open={verifyTokenDialogOpen}>
<Dialog.Root bind:open={verifyDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Verify Email</Dialog.Title>
@ -90,10 +85,10 @@ interface UpdateEmailCardProps {
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/verifyEmail" use:verifyEmailFormEnhance>
<Form.Field form={sf_verifyEmailForm} name="token">
<Form.Field form={sf_verifyEmailForm} name="code">
<Form.Control>
{#snippet children({ props })}
<InputOTP.Root maxlength={6} {...props} bind:value={$verifyEmailFormData.token}>
<InputOTP.Root maxlength={6} {...props} bind:value={$verifyEmailFormData.code}>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells as cell}