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 { StatusCodes } from '$lib/constants/status-codes';
import { HTTPException } from 'hono/http-exception' 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') { 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') { 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') { 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') { 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') { 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') { 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 { Hono } from 'hono';
import type { BlankSchema } from 'hono/types' import type { BlankSchema } from 'hono/types';
import type { HonoTypes } from './hono' import type { AppBindings } from './hono';
export abstract class Controller { export abstract class Controller {
protected readonly controller: Hono<HonoTypes, BlankSchema, '/'> protected readonly controller: Hono<AppBindings, BlankSchema, '/'>;
constructor() { 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 { PinoLogger } from 'hono-pino';
import type { Promisify, RateLimitInfo } from 'hono-rate-limiter'; import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
import type { Session, User } from 'lucia'; import type { Session, User } from 'lucia';
// export type AppOpenAPI = OpenAPIHono<AppBindings>;
export type AppOpenAPI = Hono;
export type AppBindings = { export type AppBindings = {
Variables: { Variables: {
logger: PinoLogger; logger: PinoLogger;

View file

@ -1,42 +1,59 @@
// // import type { AppOpenAPI } 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 { apiReference } from '@scalar/hono-api-reference';
// import { Hono } from 'hono'; 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 { createOpenApiDocument } from 'hono-zod-openapi'; // import { createOpenApiDocument } from 'hono-zod-openapi';
// import packageJSON from '../../../../package.json'; import { createOpenApiDocument } from 'hono-zod-openapi';
// import packageJSON from '../../../../package.json';
// // export default function configureOpenAPI(app: AppOpenAPI) {
// // app.doc('/doc', { // export default function configureOpenAPI(app: AppOpenAPI) {
// // openapi: '3.0.0', // app.doc('/doc', {
// // info: { // openapi: '3.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: Hono<AppBindings>) {
// createOpenApiDocument(app, {
// info: { // info: {
// title: 'Example API', // title: 'Bored Game API',
// version: '1.0.0', // 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 { StatusCodes } from '$lib/constants/status-codes';
import { Controller } from '$lib/server/api/common/types/controller'; 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 { changePasswordDto } from '$lib/server/api/dtos/change-password.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto'; import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.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 { LoginRequestsService } from '$lib/server/api/services/loginrequest.service';
import { LuciaService } from '$lib/server/api/services/lucia.service'; import { LuciaService } from '$lib/server/api/services/lucia.service';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { openApi } from 'hono-zod-openapi';
import { setCookie } from 'hono/cookie'; import { setCookie } from 'hono/cookie';
import { inject, injectable } from 'tsyringe'; import { inject, injectable } from 'tsyringe';
import { requireAuth } from '../middleware/require-auth.middleware'; import { requireAuth } from '../middleware/require-auth.middleware';
@ -24,22 +26,27 @@ export class IamController extends Controller {
} }
routes() { routes() {
const tags = ['IAM'];
return this.controller return this.controller
.get('/', requireAuth, async (c) => { .get('/', requireAuth, openApi(iam), async (c) => {
const user = c.var.user; const user = c.var.user;
return c.json({ user }); return c.json({ user });
}) })
.put('/update/profile', requireAuth, zValidator('json', updateProfileDto), limiter({ limit: 30, minutes: 60 }), async (c) => { .put(
const user = c.var.user; '/update/profile',
const { firstName, lastName, username } = c.req.valid('json'); requireAuth,
const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username }); openApi(updateProfile),
if (!updatedUser) { zValidator('json', updateProfileDto),
return c.json('Username already in use', StatusCodes.BAD_REQUEST); limiter({ limit: 30, minutes: 60 }),
} async (c) => {
return c.json({ user: updatedUser }, StatusCodes.OK); 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) => { .post('/verify/password', requireAuth, zValidator('json', verifyPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const user = c.var.user; const user = c.var.user;
const { password } = c.req.valid('json'); 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 { createId as cuid2 } from '@paralleldrive/cuid2';
import { type InferSelectModel, relations } from 'drizzle-orm' import { type InferSelectModel, relations } from 'drizzle-orm';
import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core' import { boolean, pgTable, text, uuid } from 'drizzle-orm/pg-core';
import { timestamps } from '../../common/utils/table' import { createSelectSchema } from 'drizzle-zod';
import { user_roles } from './userRoles.table' import { timestamps } from '../../common/utils/table';
import { user_roles } from './userRoles.table';
export const usersTable = pgTable('users', { export const usersTable = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@ -20,10 +21,12 @@ export const usersTable = pgTable('users', {
mfa_enabled: boolean('mfa_enabled').notNull().default(false), mfa_enabled: boolean('mfa_enabled').notNull().default(false),
theme: text('theme').default('system'), theme: text('theme').default('system'),
...timestamps, ...timestamps,
}) });
export const userRelations = relations(usersTable, ({ many }) => ({ export const userRelations = relations(usersTable, ({ many }) => ({
user_roles: many(user_roles), 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 'reflect-metadata';
import createApp from '$lib/server/api/common/create-app'; 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 { CollectionController } from '$lib/server/api/controllers/collection.controller';
import { MfaController } from '$lib/server/api/controllers/mfa.controller'; import { MfaController } from '$lib/server/api/controllers/mfa.controller';
import { OAuthController } from '$lib/server/api/controllers/oauth.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 { UserController } from '$lib/server/api/controllers/user.controller';
import { WishlistController } from '$lib/server/api/controllers/wishlist.controller'; import { WishlistController } from '$lib/server/api/controllers/wishlist.controller';
import { AuthCleanupJobs } from '$lib/server/api/jobs/auth-cleanup.job'; import { AuthCleanupJobs } from '$lib/server/api/jobs/auth-cleanup.job';
import { extendZodWithOpenApi } from 'hono-zod-openapi';
import { hc } from 'hono/client'; import { hc } from 'hono/client';
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { z } from 'zod';
import { config } from './common/config'; import { config } from './common/config';
import { IamController } from './controllers/iam.controller'; import { IamController } from './controllers/iam.controller';
import { LoginController } from './controllers/login.controller'; import { LoginController } from './controllers/login.controller';
export const app = createApp(); extendZodWithOpenApi(z);
// configureOpenAPI(app); export const app = createApp();
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Routes */ /* Routes */
@ -31,6 +34,9 @@ const routes = app
.route('/mfa', container.resolve(MfaController).routes()) .route('/mfa', container.resolve(MfaController).routes())
.get('/', (c) => c.json({ message: 'Server is healthy' })); .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 */ /* Cron Jobs */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

View file

@ -1,8 +1,8 @@
import { usersTable } from '$lib/server/api/databases/tables/users.table' import { usersTable } from '$lib/server/api/databases/tables/users.table';
import { DrizzleService } from '$lib/server/api/services/drizzle.service' import { DrizzleService } from '$lib/server/api/services/drizzle.service';
import { type InferInsertModel, eq } from 'drizzle-orm' import { type InferInsertModel, eq } from 'drizzle-orm';
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe';
import { takeFirstOrThrow } from '../common/utils/repository' import { takeFirstOrThrow } from '../common/utils/repository';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* 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. In our case the method 'trxHost' is used to set the transaction context.
*/ */
export type CreateUser = InferInsertModel<typeof usersTable> export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser> export type UpdateUser = Partial<CreateUser>;
@injectable() @injectable()
export class UsersRepository { export class UsersRepository {
@ -30,36 +30,36 @@ export class UsersRepository {
async findOneById(id: string, db = this.drizzle.db) { async findOneById(id: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({ return db.query.usersTable.findFirst({
where: eq(usersTable.id, id), where: eq(usersTable.id, id),
}) });
} }
async findOneByIdOrThrow(id: string, db = this.drizzle.db) { async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const user = await this.findOneById(id) const user = await this.findOneById(id);
if (!user) throw Error('User not found') if (!user) throw Error('User not found');
return user return user;
} }
async findOneByUsername(username: string, db = this.drizzle.db) { async findOneByUsername(username: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({ return db.query.usersTable.findFirst({
where: eq(usersTable.username, username), where: eq(usersTable.username, username),
}) });
} }
async findOneByEmail(email: string, db = this.drizzle.db) { async findOneByEmail(email: string, db = this.drizzle.db) {
return db.query.usersTable.findFirst({ return db.query.usersTable.findFirst({
where: eq(usersTable.email, email), where: eq(usersTable.email, email),
}) });
} }
async create(data: CreateUser, db = this.drizzle.db) { 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) { 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) { 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 { notSignedInMessage } from '$lib/flashMessages';
import { usersTable } from '$lib/server/api/databases/tables' import { usersTable } from '$lib/server/api/databases/tables';
import { db } from '$lib/server/api/packages/drizzle' import { db } from '$lib/server/api/packages/drizzle';
import { type Actions, fail } from '@sveltejs/kit' import { type Actions, fail } from '@sveltejs/kit';
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm';
import { redirect } from 'sveltekit-flash-message/server' import { redirect } from 'sveltekit-flash-message/server';
import { zod } from 'sveltekit-superforms/adapters' import { zod } from 'sveltekit-superforms/adapters';
import { message, setError, superValidate } from 'sveltekit-superforms/server' import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { z } from 'zod' import { z } from 'zod';
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types';
import { updateEmailSchema, updateProfileSchema } from './schemas' import { updateEmailFormSchema, updateProfileFormSchema } from './schemas';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const { locals } = event const { locals } = event;
const authedUser = await locals.getAuthedUser() const authedUser = await locals.getAuthedUser();
if (!authedUser) { 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: { defaults: {
firstName: authedUser?.firstName ?? '', firstName: authedUser?.firstName ?? '',
lastName: authedUser?.lastName ?? '', lastName: authedUser?.lastName ?? '',
username: authedUser?.username ?? '', username: authedUser?.username ?? '',
}, },
}) });
const emailForm = await superValidate(zod(updateEmailSchema), { const updateEmailForm = await superValidate(zod(updateEmailFormSchema), {
defaults: { defaults: {
email: authedUser?.email ?? '', email: authedUser?.email ?? '',
}, },
}) });
// const twoFactorDetails = await db.query.twoFactor.findFirst({ // const twoFactorDetails = await db.query.twoFactor.findFirst({
// where: eq(twoFactor.userId, authedUser!.id!), // where: eq(twoFactor.userId, authedUser!.id!),
// }); // });
return { return {
profileForm, updateProfileForm,
emailForm, updateEmailForm,
hasSetupTwoFactor: false, //!!twoFactorDetails?.enabled, hasSetupTwoFactor: false, //!!twoFactorDetails?.enabled,
} };
} };
const changeEmailIfNotEmpty = z.object({ 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' }), 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 = { export const actions: Actions = {
profileUpdate: async (event) => { profileUpdate: async (event) => {
const { locals } = event const { locals } = event;
const authedUser = await locals.getAuthedUser() const authedUser = await locals.getAuthedUser();
if (!authedUser) { 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) const { error } = await locals.api.me.update.profile.$put({ json: form.data }).then(locals.parseApiResponse);
console.log('data from profile update', error) console.log('data from profile update', error);
if (error) { if (error) {
return setError(form, 'username', error) return setError(form, 'username', error);
} }
if (!form.valid) { if (!form.valid) {
return fail(400, { return fail(400, {
form, form,
}) });
} }
console.log('profile updated successfully') console.log('profile updated successfully');
return message(form, { type: 'success', message: 'Profile updated successfully!' }) return message(form, { type: 'success', message: 'Profile updated successfully!' });
}, },
changeEmail: async (event) => { 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)) { if (!form.valid || !newEmail || (newEmail !== '' && !changeEmailIfNotEmpty.safeParse(form.data).success)) {
return fail(400, { return fail(400, {
form, form,
}) });
} }
if (!event.locals.user) { 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({ const existingUser = await db.query.usersTable.findFirst({
where: eq(usersTable.email, newEmail), where: eq(usersTable.email, newEmail),
}) });
if (existingUser && existingUser.id !== user.id) { 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) { // if (user.email !== form.data.email) {
// Send email to confirm new 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"> <script lang="ts">
import * as Alert from '$components/ui/alert' import * as Alert from '$components/ui/alert'
import * as Form from '$components/ui/form'
import { Button } from '$components/ui/button' import { Button } from '$components/ui/button'
import { Input } from '$components/ui/input' import { Input } from '$components/ui/input'
// import * as Form from '$lib/components/ui/form';
import { Label } from '$components/ui/label' import { Label } from '$components/ui/label'
import { AlertTriangle, KeyRound } from 'lucide-svelte' import { AlertTriangle, KeyRound } from 'lucide-svelte'
import * as flashModule from 'sveltekit-flash-message/client' import * as flashModule from 'sveltekit-flash-message/client'
import { zodClient } from 'sveltekit-superforms/adapters' import { zodClient } from 'sveltekit-superforms/adapters'
import { superForm } from 'sveltekit-superforms/client' import { superForm } from 'sveltekit-superforms/client'
import { updateEmailSchema, updateProfileSchema } from './schemas' import { updateEmailFormSchema, updateProfileFormSchema } from './schemas'
const { data } = $props() const { data } = $props()
const { updateEmailForm, updateProfileForm }: UpdateProfileProps = data;
const { const sf_updateProfileForm = superForm(updateProfileForm, {
form: profileForm, validators: zodClient(updateProfileFormSchema),
errors: profileErrors, resetForm: false,
enhance: profileEnhance, onUpdated: ({ form }) => {
} = superForm(data.profileForm, { if (!form.valid) return;
taintedMessage: null, },
validators: zodClient(updateProfileSchema), syncFlashMessage: true,
delayMs: 500, flashMessage: {
multipleSubmits: 'prevent', module: flashModule,
},
})
const sf_updateEmailForm = superForm(updateEmailForm, {
validators: zodClient(updateEmailFormSchema),
resetForm: false,
onUpdated: ({ form }) => {
if (!form.valid) return;
},
syncFlashMessage: true, syncFlashMessage: true,
flashMessage: { flashMessage: {
module: flashModule, module: flashModule,
@ -28,66 +49,60 @@ const {
}) })
const { const {
form: emailForm, form: updateProfileFormData,
submit: submitProfileForm,
enhance: updateProfileFormEnhance,
} = sf_updateProfileForm;
const {
form: updateEmailFormData,
submit: submitEmailForm,
enhance: updateEmailFormEnhance,
errors: emailErrors, errors: emailErrors,
enhance: emailEnhance, } = sf_updateEmailForm;
} = superForm(data.emailForm, {
taintedMessage: null,
validators: zodClient(updateEmailSchema),
delayMs: 500,
multipleSubmits: 'prevent',
syncFlashMessage: true,
flashMessage: {
module: flashModule,
},
})
</script> </script>
<form method="POST" action="?/profileUpdate" use:profileEnhance> <form method="POST" action="?/profileUpdate" use:updateProfileFormEnhance>
<h3>Your Profile</h3> <h3>Your Profile</h3>
<hr class="!border-t-2 mt-2 mb-6" /> <hr class="!border-t-2 mt-2 mb-6" />
<div class="mt-6"> <Form.Field form={sf_updateProfileForm} name="username">
<Label for="username">Username</Label> <Form.Control let:attrs>
<Input type="text" id="username" name="username" placeholder="Username" autocomplete="username" data-invalid={$profileErrors.username} bind:value={$profileForm.username} /> <Form.Label for="username">Username</Form.Label>
{#if $profileErrors.username} <Input {...attrs} bind:value={$updateProfileFormData.username} />
<small>{$profileErrors.username}</small> <Form.Description />
{/if} <Form.FieldErrors />
</div> </Form.Control>
<div class="mt-6"> </Form.Field>
<Label for="firstName">First Name</Label> <Form.Field form={sf_updateProfileForm} name="firstName">
<Input type="text" id="firstName" name="firstName" placeholder="First Name" autocomplete="given-name" data-invalid={$profileErrors.firstName} bind:value={$profileForm.firstName} /> <Form.Control let:attrs>
{#if $profileErrors.firstName} <Form.Label for="firstName">First Name</Form.Label>
<small>{$profileErrors.firstName}</small> <Input {...attrs} bind:value={$updateProfileFormData.firstName} />
{/if} <Form.Description />
</div> <Form.FieldErrors />
<div class="mt-6"> </Form.Control>
<Label for="lastName">Last Name</Label> </Form.Field>
<Input type="text" id="lastName" name="lastName" placeholder="Last Name" autocomplete="family-name" data-invalid={$profileErrors.lastName} bind:value={$profileForm.lastName} /> <Form.Field form={sf_updateProfileForm} name="lastName">
{#if $profileErrors.lastName} <Form.Control let:attrs>
<small>{$profileErrors.lastName}</small> <Form.Label for="lastName">Last Name</Form.Label>
{/if} <Input {...attrs} bind:value={$updateProfileFormData.lastName} />
</div> <Form.Description />
<Button type="submit" class="w-full mt-3">Update Profile</Button> <Form.FieldErrors />
</Form.Control>
</Form.Field>
<Form.Button on:click={() => submitProfileForm()} class="w-full">Update Profile</Form.Button>
</form> </form>
<form method="POST" action="?/changeEmail" use:emailEnhance> <form method="POST" action="?/changeEmail" use:updateEmailFormEnhance>
<div class="grid gap-2 mt-6"> <div class="grid gap-2 mt-6">
<Label for="email">Email address</Label> <Form.Field form={sf_updateEmailForm} name="email">
<Input <Form.Control let:attrs>
type="email" <Form.Label for="email">Email address</Form.Label>
id="email" <Input {...attrs} bind:value={$updateEmailFormData.email} />
name="email" <Form.Description />
placeholder="Email Address" <Form.FieldErrors />
autocapitalize="none" </Form.Control>
autocorrect="off" </Form.Field>
autocomplete="email" <Form.Button on:click={() => submitEmailForm()} class="w-full">Update Email</Form.Button>
data-invalid={$emailErrors.email} {#if !$updateEmailFormData.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}
<Alert.Root variant="destructive"> <Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" /> <AlertTriangle class="h-4 w-4" />
<Alert.Title>Heads up!</Alert.Title> <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' }), 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 firstName: z
.string() .string()
.trim() .trim()
@ -15,6 +15,6 @@ export const updateProfileSchema = z.object({
.optional(), .optional(),
lastName: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }).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' }), 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>;