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'; import { z } from 'zod';
export const verifyEmailDto = z.object({ export const verifyEmailDto = z.object({
token: z.string() code: z.string().length(6),
}); });
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>; 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: 'session'): AuthedReturnType;
export function authState(state: 'none'): UnauthedReturnType; export function authState(state: 'none'): UnauthedReturnType;
export function authState(state: AuthStates): AuthedReturnType | UnauthedReturnType { export function authState(state: AuthStates): AuthedReturnType | UnauthedReturnType {
if (state === 'session') return authed; if (state === 'session') {
return authed;
}
return unauthed; return unauthed;
} }

View file

@ -30,6 +30,7 @@ export class IamController extends Controller {
routes() { routes() {
return this.controller return this.controller
.post('/login', openApi(signInEmail), authState('none'), zValidator('json', signinDto), rateLimit({ limit: 3, minutes: 1 }), async (c) => { .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')); const session = await this.loginRequestsService.login(c.req.valid('json'));
await this.sessionsService.setSessionCookie(session); await this.sessionsService.setSessionCookie(session);
return c.json({ message: 'welcome' }); 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 { BadRequest } from '../../common/utils/exceptions';
import { UsersRepository } from '../users.repository'; import { UsersRepository } from '../users.repository';
import { VerificationCodesService } from '../../common/services/verification-codes.service'; import { VerificationCodesService } from '../../common/services/verification-codes.service';
import { LoggerService } from '../../common/services/logger.service';
@injectable() @injectable()
export class EmailChangeRequestsService { export class EmailChangeRequestsService {
constructor( constructor(
private emailChangeRequetsRepository = inject(EmailChangeRequestsRepository), private emailChangeRequetsRepository = inject(EmailChangeRequestsRepository),
private loggerService = inject(LoggerService),
private verificationCodesService = inject(VerificationCodesService), private verificationCodesService = inject(VerificationCodesService),
private mailerService = inject(MailerService), private mailerService = inject(MailerService),
private usersRepository = inject(UsersRepository) private usersRepository = inject(UsersRepository)
@ -44,19 +46,24 @@ export class EmailChangeRequestsService {
to: requestedEmail, to: requestedEmail,
template: new EmailChangeRequestEmail(verificationCode) template: new EmailChangeRequestEmail(verificationCode)
}); });
this.loggerService.log.info(`Email change request sent to ${requestedEmail}`);
} }
async verifyEmailChange(userId: string, verificationCode: string) { async verifyEmailChange(userId: string, verificationCode: string) {
// Get the email change request // Get the email change request
const emailChangeRequest = await this.emailChangeRequetsRepository.get(userId); const emailChangeRequest = await this.emailChangeRequetsRepository.get(userId);
if (!emailChangeRequest) throw BadRequest('Bad Request'); if (!emailChangeRequest) {
throw BadRequest('Bad Request');
}
// Verify the verification code // Verify the verification code
const isValid = await this.verificationCodesService.verify({ const isValid = await this.verificationCodesService.verify({
verificationCode, verificationCode,
hashedVerificationCode: emailChangeRequest.hashedCode hashedVerificationCode: emailChangeRequest.hashedCode
}); });
if (!isValid) throw BadRequest('Bad Request'); if (!isValid) {
throw BadRequest('Bad Request');
}
// Update the account's email // Update the account's email
await this.usersRepository.update(userId, { 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 { zValidator } from '@hono/zod-validator';
import { updateUserDto } from './dtos/update-user.dto'; import { updateUserDto } from './dtos/update-user.dto';
import { EmailChangeRequestsService } from './email-change-requests/email-change-requests.service'; 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 { 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'; import { rateLimit } from '../common/middleware/rate-limit.middleware';
@injectable() @injectable()
@ -39,6 +39,7 @@ export class UsersController extends Controller {
zValidator('json', updateEmailDto), zValidator('json', updateEmailDto),
rateLimit({ limit: 5, minutes: 15 }), rateLimit({ limit: 5, minutes: 15 }),
async (c) => { async (c) => {
c.var.logger.info(`Request email change: ${c.req.valid('json').email}`);
await this.emailChangeRequestsService.requestEmailChange( await this.emailChangeRequestsService.requestEmailChange(
c.var.session.userId, c.var.session.userId,
c.req.valid('json').email c.req.valid('json').email

View file

@ -1,32 +1,47 @@
import { zod } from "sveltekit-superforms/adapters"; import { zod } from "sveltekit-superforms/adapters";
import { fail, setError, superValidate } from "sveltekit-superforms"; import { fail, setError, superValidate } from "sveltekit-superforms";
import { StatusCodes } from "@/constants/status-codes.js"; 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) => { export const load = async (event) => {
const authedUser = await event.locals.getAuthedUserOrThrow() const { parent } = event;
const { authedUser } = await parent();
if (!authedUser) {
throw redirect('/login', notSignedInMessage, event);
}
return { return {
authedUser, updateEmailForm: await superValidate(zod(updateEmailDto), {
updateEmailForm: await superValidate(authedUser, zod(updateEmailFormSchema)), defaults: {
verifyEmailForm: await superValidate(zod(verifyEmailFormSchema)) email: authedUser.email
}
}),
verifyEmailForm: await superValidate(zod(verifyEmailDto))
}; };
}; };
export const actions = { export const actions = {
updateEmail: async ({ request, locals }) => { 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 }) 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); if (error) return setError(updateEmailForm, 'email', error);
return { updateEmailForm } return { updateEmailForm }
}, },
verifyEmail: async ({ request, locals }) => { verifyEmail: async ({ request, locals }) => {
const verifyEmailForm = await superValidate(request, zod(verifyEmailFormSchema)); const verifyEmailForm = await superValidate(request, zod(verifyEmailDto));
console.log(verifyEmailForm) console.log(verifyEmailForm)
if (!verifyEmailForm.valid) return fail(StatusCodes.BAD_REQUEST, { verifyEmailForm }) if (!verifyEmailForm.valid) {
const { error } = await locals.api.iam.email.verify.$post({ json: verifyEmailForm.data }).then(locals.parseApiResponse); return fail(StatusCodes.BAD_REQUEST, { verifyEmailForm });
if (error) return setError(verifyEmailForm, 'token', error); }
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 } 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"> <script lang="ts">
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js'; import * as Card from '$lib/components/ui/card/index.js';
@ -15,28 +6,32 @@ interface UpdateEmailCardProps {
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import * as Dialog from '@/components/ui/dialog'; import * as Dialog from '@/components/ui/dialog';
import * as InputOTP from '$lib/components/ui/input-otp/index.js'; import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import type { updateEmailDto } from '$lib/dtos/update-email.dto'; import type { UpdateEmailDto } from '$lib/dtos/settings/update-email.dto';
import type { verifyEmailDto } from '$lib/dtos/verify-email.dto'; import type { VerifyEmailDto } from '$lib/dtos/settings/verify-email.dto';
/* ---------------------------------- props --------------------------------- */ /* ---------------------------------- props --------------------------------- */
let { updateEmailForm, verifyEmailForm }: UpdateEmailCardProps = $props(); let { updateEmailForm, verifyEmailForm }: { updateEmailForm: UpdateEmailDto; verifyEmailForm: VerifyEmailDto } = $props();
/* ---------------------------------- state --------------------------------- */ /* ---------------------------------- state --------------------------------- */
let verifyTokenDialogOpen = $state(false); let verifyDialogOpen = $state(false);
/* ---------------------------------- forms --------------------------------- */ /* ---------------------------------- forms --------------------------------- */
const sf_updateEmailForm = superForm(updateEmailForm, { const sf_updateEmailForm = superForm(updateEmailForm, {
resetForm: false, resetForm: false,
onUpdated: ({ form }) => { onUpdated: ({ form }) => {
if (!form.valid) return; if (!form.valid) {
verifyTokenDialogOpen = true; return;
}
verifyDialogOpen = true;
} }
}); });
const sf_verifyEmailForm = superForm(verifyEmailForm, { const sf_verifyEmailForm = superForm(verifyEmailForm, {
onUpdated: ({ form }) => { onUpdated: ({ form }) => {
if (!form.valid) return; if (!form.valid) {
verifyTokenDialogOpen = false; return;
}
verifyDialogOpen = false;
} }
}); });
@ -80,7 +75,7 @@ interface UpdateEmailCardProps {
</Card.Root> </Card.Root>
<!-- Dialogs --> <!-- Dialogs -->
<Dialog.Root bind:open={verifyTokenDialogOpen}> <Dialog.Root bind:open={verifyDialogOpen}>
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Verify Email</Dialog.Title> <Dialog.Title>Verify Email</Dialog.Title>
@ -90,10 +85,10 @@ interface UpdateEmailCardProps {
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/verifyEmail" use:verifyEmailFormEnhance> <form method="POST" action="?/verifyEmail" use:verifyEmailFormEnhance>
<Form.Field form={sf_verifyEmailForm} name="token"> <Form.Field form={sf_verifyEmailForm} name="code">
<Form.Control> <Form.Control>
{#snippet children({ props })} {#snippet children({ props })}
<InputOTP.Root maxlength={6} {...props} bind:value={$verifyEmailFormData.token}> <InputOTP.Root maxlength={6} {...props} bind:value={$verifyEmailFormData.code}>
{#snippet children({ cells })} {#snippet children({ cells })}
<InputOTP.Group> <InputOTP.Group>
{#each cells as cell} {#each cells as cell}