Using a different OpenAPI dependency for hono for now, adding docs for IAM.

This commit is contained in:
Bradley Shellnut 2024-10-10 16:40:49 -07:00
parent 47ae91e015
commit e48c9b3e09
12 changed files with 317 additions and 203 deletions

View file

@ -1,26 +1,40 @@
import { StatusCodes } from '$lib/constants/status-codes'
import { HTTPException } from 'hono/http-exception'
import { StatusCodes } from '$lib/constants/status-codes';
import { HTTPException } from 'hono/http-exception';
import * as HttpStatusPhrases from 'stoker/http-status-phrases';
import { createMessageObjectSchema } from 'stoker/openapi/schemas';
export function TooManyRequests(message = 'Too many requests') {
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message })
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
}
export const tooManyRequestsSchema = createMessageObjectSchema(HttpStatusPhrases.TOO_MANY_REQUESTS);
export function Forbidden(message = 'Forbidden') {
return new HTTPException(StatusCodes.FORBIDDEN, { message })
return new HTTPException(StatusCodes.FORBIDDEN, { message });
}
export const forbiddenSchema = createMessageObjectSchema(HttpStatusPhrases.FORBIDDEN);
export function Unauthorized(message = 'Unauthorized') {
return new HTTPException(StatusCodes.UNAUTHORIZED, { message })
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
}
export const unauthorizedSchema = createMessageObjectSchema(HttpStatusPhrases.UNAUTHORIZED);
export function NotFound(message = 'Not Found') {
return new HTTPException(StatusCodes.NOT_FOUND, { message })
return new HTTPException(StatusCodes.NOT_FOUND, { message });
}
export const notFoundSchema = createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND);
export function BadRequest(message = 'Bad Request') {
return new HTTPException(StatusCodes.BAD_REQUEST, { message })
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
}
export const badRequestSchema = createMessageObjectSchema(HttpStatusPhrases.BAD_REQUEST);
export function InternalError(message = 'Internal Error') {
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message })
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
}
export const internalErrorSchema = createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR);

View file

@ -1,11 +1,11 @@
import { Hono } from 'hono'
import type { BlankSchema } from 'hono/types'
import type { HonoTypes } from './hono'
import { Hono } from 'hono';
import type { BlankSchema } from 'hono/types';
import type { AppBindings } from './hono';
export abstract class Controller {
protected readonly controller: Hono<HonoTypes, BlankSchema, '/'>
protected readonly controller: Hono<AppBindings, BlankSchema, '/'>;
constructor() {
this.controller = new Hono()
this.controller = new Hono();
}
abstract routes(): Hono<HonoTypes, BlankSchema, '/'>
abstract routes(): Hono<AppBindings, BlankSchema, '/'>;
}

View file

@ -1,7 +1,11 @@
import type { Hono } from 'hono';
import type { PinoLogger } from 'hono-pino';
import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
import type { Session, User } from 'lucia';
// export type AppOpenAPI = OpenAPIHono<AppBindings>;
export type AppOpenAPI = Hono;
export type AppBindings = {
Variables: {
logger: PinoLogger;

View file

@ -1,42 +1,59 @@
// // import type { AppOpenAPI } from '$lib/server/api/common/types/hono';
// import { apiReference } from '@scalar/hono-api-reference';
// import { Hono } from 'hono';
//
// import type { AppBindings } from '$lib/server/api/common/types/hono';
// import type { AppOpenAPI } from '$lib/server/api/common/types/hono';
import { apiReference } from '@scalar/hono-api-reference';
import { Hono } from 'hono';
import type { AppOpenAPI } from '$lib/server/api/common/types/hono';
// import { createOpenApiDocument } from 'hono-zod-openapi';
// import packageJSON from '../../../../package.json';
//
// // export default function configureOpenAPI(app: AppOpenAPI) {
// // app.doc('/doc', {
// // openapi: '3.0.0',
// // info: {
// // title: 'Bored Game API',
// // description: 'Bored Game API',
// // version: packageJSON.version,
// // },
// // });
// //
// // app.get(
// // '/reference',
// // apiReference({
// // theme: 'kepler',
// // layout: 'classic',
// // defaultHttpClient: {
// // targetKey: 'javascript',
// // clientKey: 'fetch',
// // },
// // spec: {
// // url: '/api/doc',
// // },
// // }),
// // );
// // }
//
// export default function configureOpenAPI(app: Hono<AppBindings>) {
// createOpenApiDocument(app, {
import { createOpenApiDocument } from 'hono-zod-openapi';
import packageJSON from '../../../../package.json';
// export default function configureOpenAPI(app: AppOpenAPI) {
// app.doc('/doc', {
// openapi: '3.0.0',
// info: {
// title: 'Example API',
// version: '1.0.0',
// title: 'Bored Game API',
// description: 'Bored Game API',
// version: packageJSON.version,
// },
// });
//
// app.get(
// '/reference',
// apiReference({
// theme: 'kepler',
// layout: 'classic',
// defaultHttpClient: {
// targetKey: 'javascript',
// clientKey: 'fetch',
// },
// spec: {
// url: '/api/doc',
// },
// }),
// );
// }
export default function configureOpenAPI(app: AppOpenAPI) {
createOpenApiDocument(app, {
info: {
title: 'Bored Game API',
description: 'Bored Game API',
version: packageJSON.version,
},
});
app.get(
'/reference',
apiReference({
theme: 'kepler',
layout: 'classic',
defaultHttpClient: {
targetKey: 'javascript',
clientKey: 'fetch',
},
spec: {
url: '/api/doc',
},
}),
);
}

View file

@ -1,5 +1,6 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { Controller } from '$lib/server/api/common/types/controller';
import { iam, updateProfile } from '$lib/server/api/controllers/iam.routes';
import { changePasswordDto } from '$lib/server/api/dtos/change-password.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
@ -9,6 +10,7 @@ import { IamService } from '$lib/server/api/services/iam.service';
import { LoginRequestsService } from '$lib/server/api/services/loginrequest.service';
import { LuciaService } from '$lib/server/api/services/lucia.service';
import { zValidator } from '@hono/zod-validator';
import { openApi } from 'hono-zod-openapi';
import { setCookie } from 'hono/cookie';
import { inject, injectable } from 'tsyringe';
import { requireAuth } from '../middleware/require-auth.middleware';
@ -24,22 +26,27 @@ export class IamController extends Controller {
}
routes() {
const tags = ['IAM'];
return this.controller
.get('/', requireAuth, async (c) => {
.get('/', requireAuth, openApi(iam), async (c) => {
const user = c.var.user;
return c.json({ user });
})
.put('/update/profile', requireAuth, zValidator('json', updateProfileDto), limiter({ limit: 30, minutes: 60 }), async (c) => {
const user = c.var.user;
const { firstName, lastName, username } = c.req.valid('json');
const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username });
if (!updatedUser) {
return c.json('Username already in use', StatusCodes.BAD_REQUEST);
}
return c.json({ user: updatedUser }, StatusCodes.OK);
})
.put(
'/update/profile',
requireAuth,
openApi(updateProfile),
zValidator('json', updateProfileDto),
limiter({ limit: 30, minutes: 60 }),
async (c) => {
const user = c.var.user;
const { firstName, lastName, username } = c.req.valid('json');
const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username });
if (!updatedUser) {
return c.json('Username already in use', StatusCodes.BAD_REQUEST);
}
return c.json({ user: updatedUser }, StatusCodes.OK);
},
)
.post('/verify/password', requireAuth, zValidator('json', verifyPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const user = c.var.user;
const { password } = c.req.valid('json');

View file

@ -0,0 +1,48 @@
import { StatusCodes } from '$lib/constants/status-codes';
import { unauthorizedSchema } from '$lib/server/api/common/exceptions';
import { selectUserSchema } from '$lib/server/api/databases/tables/users.table';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import { defineOpenApiOperation } from 'hono-zod-openapi';
import { createErrorSchema } from 'stoker/openapi/schemas';
const tags = ['IAM'];
export const iam = defineOpenApiOperation({
tags,
responses: {
[StatusCodes.OK]: {
description: 'User profile',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});
export const updateProfile = defineOpenApiOperation({
tags,
request: {
json: updateProfileDto,
},
responses: {
[StatusCodes.OK]: {
description: 'Updated User',
schema: selectUserSchema,
mediaType: 'application/json',
},
[StatusCodes.UNPROCESSABLE_ENTITY]: {
description: 'The validation error(s)',
schema: createErrorSchema(updateProfileDto),
mediaType: 'application/json',
},
[StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema),
mediaType: 'application/json',
},
},
});

View file

@ -1,8 +1,9 @@
import { createId as cuid2 } from '@paralleldrive/cuid2'
import { type InferSelectModel, relations } from 'drizzle-orm'
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { timestamps } from '../../common/utils/table'
import { user_roles } from './userRoles.table'
import { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm';
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { createSelectSchema } from 'drizzle-zod';
import { timestamps } from '../../common/utils/table';
import { user_roles } from './userRoles.table';
export const usersTable = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
@ -20,10 +21,12 @@ export const usersTable = pgTable('users', {
mfa_enabled: boolean('mfa_enabled').notNull().default(false),
theme: text('theme').default('system'),
...timestamps,
})
});
export const userRelations = relations(usersTable, ({ many }) => ({
user_roles: many(user_roles),
}))
}));
export type Users = InferSelectModel<typeof usersTable>
export const selectUserSchema = createSelectSchema(usersTable);
export type Users = InferSelectModel<typeof usersTable>;

View file

@ -1,5 +1,6 @@
import 'reflect-metadata';
import createApp from '$lib/server/api/common/create-app';
import configureOpenAPI from '$lib/server/api/configure-open-api';
import { CollectionController } from '$lib/server/api/controllers/collection.controller';
import { MfaController } from '$lib/server/api/controllers/mfa.controller';
import { OAuthController } from '$lib/server/api/controllers/oauth.controller';
@ -7,15 +8,17 @@ import { SignupController } from '$lib/server/api/controllers/signup.controller'
import { UserController } from '$lib/server/api/controllers/user.controller';
import { WishlistController } from '$lib/server/api/controllers/wishlist.controller';
import { AuthCleanupJobs } from '$lib/server/api/jobs/auth-cleanup.job';
import { extendZodWithOpenApi } from 'hono-zod-openapi';
import { hc } from 'hono/client';
import { container } from 'tsyringe';
import { z } from 'zod';
import { config } from './common/config';
import { IamController } from './controllers/iam.controller';
import { LoginController } from './controllers/login.controller';
export const app = createApp();
extendZodWithOpenApi(z);
// configureOpenAPI(app);
export const app = createApp();
/* -------------------------------------------------------------------------- */
/* Routes */
@ -31,6 +34,9 @@ const routes = app
.route('/mfa', container.resolve(MfaController).routes())
.get('/', (c) => c.json({ message: 'Server is healthy' }));
// @ts-ignore - this is a workaround for https://github.com/paolostyle/hono-zod-openapi/issues/2
configureOpenAPI(app);
/* -------------------------------------------------------------------------- */
/* Cron Jobs */
/* -------------------------------------------------------------------------- */

View file

@ -1,8 +1,8 @@
import { usersTable } from '$lib/server/api/databases/tables/users.table'
import { DrizzleService } from '$lib/server/api/services/drizzle.service'
import { type InferInsertModel, eq } from 'drizzle-orm'
import { inject, injectable } from 'tsyringe'
import { takeFirstOrThrow } from '../common/utils/repository'
import { usersTable } from '$lib/server/api/databases/tables/users.table';
import { DrizzleService } from '$lib/server/api/services/drizzle.service';
import { type InferInsertModel, eq } from 'drizzle-orm';
import { inject, injectable } from 'tsyringe';
import { takeFirstOrThrow } from '../common/utils/repository';
/* -------------------------------------------------------------------------- */
/* Repository */
@ -20,8 +20,8 @@ storing data. They should not contain any business logic, only database queries.
In our case the method 'trxHost' is used to set the transaction context.
*/
export type CreateUser = InferInsertModel<typeof usersTable>
export type UpdateUser = Partial<CreateUser>
export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>;
@injectable()
export class UsersRepository {
@ -30,36 +30,36 @@ export class UsersRepository {
async findOneById(id: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.id, id),
})
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const user = await this.findOneById(id)
if (!user) throw Error('User not found')
return user
const user = await this.findOneById(id);
if (!user) throw Error('User not found');
return user;
}
async findOneByUsername(username: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.username, username),
})
});
}
async findOneByEmail(email: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({
where: eq(usersTable.email, email),
})
});
}
async create(data: CreateUser, db = this.drizzle.db) {
return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow)
return db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser, db = this.drizzle.db) {
return db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow)
return db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(usersTable).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow)
return db.delete(usersTable).where(eq(usersTable.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -1,102 +1,102 @@
import { notSignedInMessage } from '$lib/flashMessages'
import { usersTable } from '$lib/server/api/databases/tables'
import { db } from '$lib/server/api/packages/drizzle'
import { type Actions, fail } from '@sveltejs/kit'
import { eq } from 'drizzle-orm'
import { redirect } from 'sveltekit-flash-message/server'
import { zod } from 'sveltekit-superforms/adapters'
import { message, setError, superValidate } from 'sveltekit-superforms/server'
import { z } from 'zod'
import type { PageServerLoad } from './$types'
import { updateEmailSchema, updateProfileSchema } from './schemas'
import { notSignedInMessage } from '$lib/flashMessages';
import { usersTable } from '$lib/server/api/databases/tables';
import { db } from '$lib/server/api/packages/drizzle';
import { type Actions, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { redirect } from 'sveltekit-flash-message/server';
import { zod } from 'sveltekit-superforms/adapters';
import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod';
import type { PageServerLoad } from './$types';
import { updateEmailFormSchema, updateProfileFormSchema } from './schemas';
export const load: PageServerLoad = async (event) => {
const { locals } = event
const { locals } = event;
const authedUser = await locals.getAuthedUser()
const authedUser = await locals.getAuthedUser();
if (!authedUser) {
throw redirect(302, '/login', notSignedInMessage, event)
throw redirect(302, '/login', notSignedInMessage, event);
}
const profileForm = await superValidate(zod(updateProfileSchema), {
const updateProfileForm = await superValidate(zod(updateProfileFormSchema), {
defaults: {
firstName: authedUser?.firstName ?? '',
lastName: authedUser?.lastName ?? '',
username: authedUser?.username ?? '',
},
})
const emailForm = await superValidate(zod(updateEmailSchema), {
});
const updateEmailForm = await superValidate(zod(updateEmailFormSchema), {
defaults: {
email: authedUser?.email ?? '',
},
})
});
// const twoFactorDetails = await db.query.twoFactor.findFirst({
// where: eq(twoFactor.userId, authedUser!.id!),
// });
return {
profileForm,
emailForm,
updateProfileForm,
updateEmailForm,
hasSetupTwoFactor: false, //!!twoFactorDetails?.enabled,
}
}
};
};
const changeEmailIfNotEmpty = z.object({
email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }).email({ message: 'Please enter a valid email' }),
})
});
export const actions: Actions = {
profileUpdate: async (event) => {
const { locals } = event
const { locals } = event;
const authedUser = await locals.getAuthedUser()
const authedUser = await locals.getAuthedUser();
if (!authedUser) {
redirect(302, '/login', notSignedInMessage, event)
redirect(302, '/login', notSignedInMessage, event);
}
const form = await superValidate(event, zod(updateProfileSchema))
const form = await superValidate(event, zod(updateProfileFormSchema));
const { error } = await locals.api.me.update.profile.$put({ json: form.data }).then(locals.parseApiResponse)
console.log('data from profile update', error)
const { error } = await locals.api.me.update.profile.$put({ json: form.data }).then(locals.parseApiResponse);
console.log('data from profile update', error);
if (error) {
return setError(form, 'username', error)
return setError(form, 'username', error);
}
if (!form.valid) {
return fail(400, {
form,
})
});
}
console.log('profile updated successfully')
return message(form, { type: 'success', message: 'Profile updated successfully!' })
console.log('profile updated successfully');
return message(form, { type: 'success', message: 'Profile updated successfully!' });
},
changeEmail: async (event) => {
const form = await superValidate(event, zod(updateEmailSchema))
const form = await superValidate(event, zod(updateEmailFormSchema));
const newEmail = form.data?.email
const newEmail = form.data?.email;
if (!form.valid || !newEmail || (newEmail !== '' && !changeEmailIfNotEmpty.safeParse(form.data).success)) {
return fail(400, {
form,
})
});
}
if (!event.locals.user) {
redirect(302, '/login', notSignedInMessage, event)
redirect(302, '/login', notSignedInMessage, event);
}
const user = event.locals.user
const user = event.locals.user;
const existingUser = await db.query.usersTable.findFirst({
where: eq(usersTable.email, newEmail),
})
});
if (existingUser && existingUser.id !== user.id) {
return setError(form, 'email', 'That email is already taken')
return setError(form, 'email', 'That email is already taken');
}
await db.update(usersTable).set({ email: form.data.email }).where(eq(usersTable.id, user.id))
await db.update(usersTable).set({ email: form.data.email }).where(eq(usersTable.id, user.id));
// if (user.email !== form.data.email) {
// Send email to confirm new email?
@ -114,6 +114,6 @@ export const actions: Actions = {
// });
// }
return message(form, { type: 'success', message: 'Email updated successfully!' })
return message(form, { type: 'success', message: 'Email updated successfully!' });
},
}
};

View file

@ -1,26 +1,47 @@
<script context="module" lang="ts">
import type { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import type { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto';
import type { Infer, SuperValidated } from 'sveltekit-superforms';
interface UpdateProfileProps {
updateEmailForm: SuperValidated<Infer<typeof updateEmailDto>>;
updateProfileForm: SuperValidated<Infer<typeof updateProfileDto>>;
}
</script>
<script lang="ts">
import * as Alert from '$components/ui/alert'
import * as Form from '$components/ui/form'
import { Button } from '$components/ui/button'
import { Input } from '$components/ui/input'
// import * as Form from '$lib/components/ui/form';
import { Label } from '$components/ui/label'
import { AlertTriangle, KeyRound } from 'lucide-svelte'
import * as flashModule from 'sveltekit-flash-message/client'
import { zodClient } from 'sveltekit-superforms/adapters'
import { superForm } from 'sveltekit-superforms/client'
import { updateEmailSchema, updateProfileSchema } from './schemas'
import { updateEmailFormSchema, updateProfileFormSchema } from './schemas'
const { data } = $props()
const { updateEmailForm, updateProfileForm }: UpdateProfileProps = data;
const {
form: profileForm,
errors: profileErrors,
enhance: profileEnhance,
} = superForm(data.profileForm, {
taintedMessage: null,
validators: zodClient(updateProfileSchema),
delayMs: 500,
multipleSubmits: 'prevent',
const sf_updateProfileForm = superForm(updateProfileForm, {
validators: zodClient(updateProfileFormSchema),
resetForm: false,
onUpdated: ({ form }) => {
if (!form.valid) return;
},
syncFlashMessage: true,
flashMessage: {
module: flashModule,
},
})
const sf_updateEmailForm = superForm(updateEmailForm, {
validators: zodClient(updateEmailFormSchema),
resetForm: false,
onUpdated: ({ form }) => {
if (!form.valid) return;
},
syncFlashMessage: true,
flashMessage: {
module: flashModule,
@ -28,66 +49,60 @@ const {
})
const {
form: emailForm,
form: updateProfileFormData,
submit: submitProfileForm,
enhance: updateProfileFormEnhance,
} = sf_updateProfileForm;
const {
form: updateEmailFormData,
submit: submitEmailForm,
enhance: updateEmailFormEnhance,
errors: emailErrors,
enhance: emailEnhance,
} = superForm(data.emailForm, {
taintedMessage: null,
validators: zodClient(updateEmailSchema),
delayMs: 500,
multipleSubmits: 'prevent',
syncFlashMessage: true,
flashMessage: {
module: flashModule,
},
})
} = sf_updateEmailForm;
</script>
<form method="POST" action="?/profileUpdate" use:profileEnhance>
<form method="POST" action="?/profileUpdate" use:updateProfileFormEnhance>
<h3>Your Profile</h3>
<hr class="!border-t-2 mt-2 mb-6" />
<div class="mt-6">
<Label for="username">Username</Label>
<Input type="text" id="username" name="username" placeholder="Username" autocomplete="username" data-invalid={$profileErrors.username} bind:value={$profileForm.username} />
{#if $profileErrors.username}
<small>{$profileErrors.username}</small>
{/if}
</div>
<div class="mt-6">
<Label for="firstName">First Name</Label>
<Input type="text" id="firstName" name="firstName" placeholder="First Name" autocomplete="given-name" data-invalid={$profileErrors.firstName} bind:value={$profileForm.firstName} />
{#if $profileErrors.firstName}
<small>{$profileErrors.firstName}</small>
{/if}
</div>
<div class="mt-6">
<Label for="lastName">Last Name</Label>
<Input type="text" id="lastName" name="lastName" placeholder="Last Name" autocomplete="family-name" data-invalid={$profileErrors.lastName} bind:value={$profileForm.lastName} />
{#if $profileErrors.lastName}
<small>{$profileErrors.lastName}</small>
{/if}
</div>
<Button type="submit" class="w-full mt-3">Update Profile</Button>
<Form.Field form={sf_updateProfileForm} name="username">
<Form.Control let:attrs>
<Form.Label for="username">Username</Form.Label>
<Input {...attrs} bind:value={$updateProfileFormData.username} />
<Form.Description />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field form={sf_updateProfileForm} name="firstName">
<Form.Control let:attrs>
<Form.Label for="firstName">First Name</Form.Label>
<Input {...attrs} bind:value={$updateProfileFormData.firstName} />
<Form.Description />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Field form={sf_updateProfileForm} name="lastName">
<Form.Control let:attrs>
<Form.Label for="lastName">Last Name</Form.Label>
<Input {...attrs} bind:value={$updateProfileFormData.lastName} />
<Form.Description />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Button on:click={() => submitProfileForm()} class="w-full">Update Profile</Form.Button>
</form>
<form method="POST" action="?/changeEmail" use:emailEnhance>
<form method="POST" action="?/changeEmail" use:updateEmailFormEnhance>
<div class="grid gap-2 mt-6">
<Label for="email">Email address</Label>
<Input
type="email"
id="email"
name="email"
placeholder="Email Address"
autocapitalize="none"
autocorrect="off"
autocomplete="email"
data-invalid={$emailErrors.email}
bind:value={$emailForm.email}
/>
{#if $emailErrors.email}
<small>{$emailErrors.email}</small>
{/if}
<Button type="submit" class="w-full">Update Email</Button>
{#if !$emailForm.email}
<Form.Field form={sf_updateEmailForm} name="email">
<Form.Control let:attrs>
<Form.Label for="email">Email address</Form.Label>
<Input {...attrs} bind:value={$updateEmailFormData.email} />
<Form.Description />
<Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Button on:click={() => submitEmailForm()} class="w-full">Update Email</Form.Button>
{#if !$updateEmailFormData.email}
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title>Heads up!</Alert.Title>

View file

@ -1,12 +1,12 @@
import { z } from 'zod'
import { z } from 'zod';
export const updateEmailSchema = z.object({
export const updateEmailFormSchema = z.object({
email: z.string().trim().max(64, { message: 'Email must be less than 64 characters' }).email({ message: 'Please enter a valid email' }),
})
});
export type UpdateEmailSchema = z.infer<typeof updateEmailSchema>
export type UpdateEmailSchema = z.infer<typeof updateEmailFormSchema>;
export const updateProfileSchema = z.object({
export const updateProfileFormSchema = z.object({
firstName: z
.string()
.trim()
@ -15,6 +15,6 @@ export const updateProfileSchema = z.object({
.optional(),
lastName: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }).optional(),
username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
})
});
export type UpdateProfileSchema = z.infer<typeof updateProfileSchema>
export type UpdateProfileSchema = z.infer<typeof updateProfileFormSchema>;