mirror of
https://github.com/BradNut/boredgame
synced 2025-09-08 17:40:22 +00:00
Using a different OpenAPI dependency for hono for now, adding docs for IAM.
This commit is contained in:
parent
47ae91e015
commit
e48c9b3e09
12 changed files with 317 additions and 203 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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, '/'>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
48
src/lib/server/api/controllers/iam.routes.ts
Normal file
48
src/lib/server/api/controllers/iam.routes.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!' });
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue