Bring back mailpit, add roles, user roles, and set up login form for differnent login options.

This commit is contained in:
Bradley Shellnut 2024-12-30 09:12:26 -08:00
parent 5497fb75a7
commit cc12d19250
22 changed files with 365 additions and 115 deletions

View file

@ -36,25 +36,25 @@ services:
- MINIO_ROOT_PASSWORD=password - MINIO_ROOT_PASSWORD=password
- MINIO_DEFAULT_BUCKETS=dev - MINIO_DEFAULT_BUCKETS=dev
# mailpit: mailpit:
# image: axllent/mailpit image: axllent/mailpit
# volumes: volumes:
# - mailpit_data:/data - mailpit_data:/data
# ports: ports:
# - 8025:8025 - 8025:8025
# - 1025:1025 - 1025:1025
# environment: environment:
# MP_MAX_MESSAGES: 5000 MP_MAX_MESSAGES: 5000
# MP_DATABASE: /data/mailpit.db MP_DATABASE: /data/mailpit.db
# MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ACCEPT_ANY: 1
# MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
# networks: networks:
# - app-network - app-network
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
# mailpit_data: mailpit_data:
minio_data: minio_data:
driver: local driver: local

View file

@ -1,10 +1,12 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle, ServerInit } from '@sveltejs/kit';
import { i18n } from '$lib/i18n'; import { i18n } from '$lib/i18n';
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from '@sveltejs/kit/hooks';
import { startServer } from '$lib/server/api'; import { startServer } from '$lib/server/api';
const handleParaglide: Handle = i18n.handle(); const handleParaglide: Handle = i18n.handle();
startServer(); export const init: ServerInit = async () => {
await startServer();
};
export const handle: Handle = sequence(handleParaglide); export const handle: Handle = sequence(handleParaglide);

View file

@ -1,5 +1,7 @@
import { customType, timestamp } from 'drizzle-orm/pg-core'; import { customType, timestamp } from 'drizzle-orm/pg-core';
import { NotFound } from './exceptions'; import { NotFound } from './exceptions';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as drizzleSchema from '../../databases/postgres/drizzle-schema';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Repository */ /* Repository */
@ -51,3 +53,5 @@ export const timestamps = {
.defaultNow() .defaultNow()
.$onUpdateFn(() => new Date()) .$onUpdateFn(() => new Date())
}; };
export type Transaction = Parameters<Parameters<NodePgDatabase<typeof drizzleSchema>["transaction"]>[0]>[0];

View file

@ -8,6 +8,7 @@ import { ConfigService } from '../../common/configs/config.service';
export class DrizzleService { export class DrizzleService {
public db: NodePgDatabase<typeof drizzleSchema>; public db: NodePgDatabase<typeof drizzleSchema>;
public schema: typeof drizzleSchema = drizzleSchema; public schema: typeof drizzleSchema = drizzleSchema;
constructor(private configService = inject(ConfigService)) { constructor(private configService = inject(ConfigService)) {
this.db = drizzle( this.db = drizzle(
new Pool({ new Pool({

View file

@ -9,6 +9,7 @@ import { authState } from '../common/middleware/auth.middleware';
import { Controller } from '../common/factories/controllers.factory'; import { Controller } from '../common/factories/controllers.factory';
import { loginRequestDto } from './login-requests/dtos/login-request.dto'; import { loginRequestDto } from './login-requests/dtos/login-request.dto';
import { signInEmail } from './login-requests/routes/login.routes'; import { signInEmail } from './login-requests/routes/login.routes';
import { rateLimit } from '../common/middleware/rate-limit.middleware';
@injectable() @injectable()
export class IamController extends Controller { export class IamController extends Controller {
@ -21,7 +22,7 @@ export class IamController extends Controller {
routes() { routes() {
return this.controller return this.controller
.post('/login', openApi(signInEmail), authState('none'), zValidator('json', loginRequestDto), async (c) => { .post('/login', openApi(signInEmail), authState('none'), zValidator('json', loginRequestDto), rateLimit({ limit: 3, minutes: 1 }), async (c) => {
const session = await this.loginRequestsService.login(c.req.valid('json')); const session = await this.loginRequestsService.login(c.req.valid('json'));
await this.sessionsService.setSessionCookie(session); await this.sessionsService.setSessionCookie(session);
return c.json({ message: 'welcome' }); return c.json({ message: 'welcome' });

View file

@ -1,18 +1,19 @@
import { inject, injectable } from '@needle-di/core'; import { inject, injectable } from '@needle-di/core';
import { LoginRequestsRepository } from './login-requests.repository'; import { TokensService } from '../../common/services/tokens.service';
import { VerificationCodesService } from '../../common/services/verification-codes.service';
import { BadRequest, NotFound } from '../../common/utils/exceptions';
import { MailerService } from '../../mail/mailer.service'; import { MailerService } from '../../mail/mailer.service';
import { LoginVerificationEmail } from '../../mail/templates/login-verification.template'; import { LoginVerificationEmail } from '../../mail/templates/login-verification.template';
import { BadRequest, NotFound } from '../../common/utils/exceptions';
import { WelcomeEmail } from '../../mail/templates/welcome.template'; import { WelcomeEmail } from '../../mail/templates/welcome.template';
import { SessionsService } from '../sessions/sessions.service';
import type { VerifyLoginRequestDto } from './dtos/verify-login-request.dto';
import type { CreateLoginRequestDto } from './dtos/create-login-request.dto';
import { UsersService } from '../../users/users.service';
import { UsersRepository } from '../../users/users.repository';
import { VerificationCodesService } from '../../common/services/verification-codes.service';
import type { LoginRequestDto } from './dtos/login-request.dto';
import { CredentialsRepository } from '../../users/credentials.repository'; import { CredentialsRepository } from '../../users/credentials.repository';
import { TokensService } from '../../common/services/tokens.service'; import { UsersRepository } from '../../users/users.repository';
import { UsersService } from '../../users/users.service';
import { SessionsService } from '../sessions/sessions.service';
import type { CreateLoginRequestDto } from './dtos/create-login-request.dto';
import type { LoginRequestDto } from './dtos/login-request.dto';
import type { VerifyLoginRequestDto } from './dtos/verify-login-request.dto';
import { LoginRequestsRepository } from './login-requests.repository';
import { logger } from 'hono-pino';
@injectable() @injectable()
export class LoginRequestsService { export class LoginRequestsService {
@ -31,7 +32,7 @@ export class LoginRequestsService {
const existingUser = await this.usersRepository.findOneByEmail(email); const existingUser = await this.usersRepository.findOneByEmail(email);
if (!existingUser) { if (!existingUser) {
throw NotFound('User not found'); throw BadRequest('Invalid credentials');
} }
const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id); const credential = await this.credentialsRepository.findPasswordCredentialsByUserId(existingUser.id);
@ -40,7 +41,11 @@ export class LoginRequestsService {
throw BadRequest('Invalid credentials'); throw BadRequest('Invalid credentials');
} }
if (!(await this.tokensService.verifyHashedToken(credential.secret_data, password))) { try {
if (!(await this.tokensService.verifyHashedToken(credential.secret_data, password))) {
throw BadRequest('Invalid credentials');
}
} catch (error) {
throw BadRequest('Invalid credentials'); throw BadRequest('Invalid credentials');
} }
@ -97,7 +102,7 @@ export class LoginRequestsService {
private async authNewUser({ email }: { email: string }) { private async authNewUser({ email }: { email: string }) {
// create a new user // create a new user
const user = await this.usersService.create(email); const user = await this.usersService.createEmail(email);
// send the welcome email // send the welcome email
await this.mailer.send({ await this.mailer.send({

View file

@ -0,0 +1,53 @@
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, eq } from 'drizzle-orm';
import { takeFirstOrThrow } from '../common/utils/drizzle';
import { roles_table } from './tables/roles.table';
export type CreateRole = InferInsertModel<typeof roles_table>;
export type UpdateRole = Partial<CreateRole>;
@injectable()
export class RolesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.roles_table.findFirst({
where: eq(roles_table.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const role = await this.findOneById(id, db);
if (!role) throw Error('Role not found');
return role;
}
async findAll(db = this.drizzle.db) {
return db.query.roles_table.findMany();
}
async findOneByName(name: string, db = this.drizzle.db) {
return db.query.roles_table.findFirst({
where: eq(roles_table.name, name),
});
}
async findOneByNameOrThrow(name: string, db = this.drizzle.db) {
const role = await this.findOneByName(name, db);
if (!role) throw Error('Role not found');
return role;
}
async create(data: CreateRole, db = this.drizzle.db) {
return db.insert(roles_table).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateRole, db = this.drizzle.db) {
return db.update(roles_table).set(data).where(eq(roles_table.id, id)).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(roles_table).where(eq(roles_table.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -0,0 +1,11 @@
import { RolesRepository } from './roles.repository';
import { inject, injectable } from '@needle-di/core';
@injectable()
export class RolesService {
constructor(private rolesRepository = inject(RolesRepository)) {}
async findOneByNameOrThrow(name: string) {
return this.rolesRepository.findOneByNameOrThrow(name);
}
}

View file

@ -0,0 +1,39 @@
import { DrizzleService } from '$lib/server/api/databases/postgres/drizzle.service';
import { inject, injectable } from '@needle-di/core';
import { type InferInsertModel, eq } from 'drizzle-orm';
import { takeFirstOrThrow } from '../common/utils/drizzle';
import { user_roles_table } from './tables/user-roles.table';
export type CreateUserRole = InferInsertModel<typeof user_roles_table>;
export type UpdateUserRole = Partial<CreateUserRole>;
@injectable()
export class UserRolesRepository {
constructor(private drizzle = inject(DrizzleService)) {}
async findOneById(id: string, db = this.drizzle.db) {
return db.query.user_roles_table.findFirst({
where: eq(user_roles_table.id, id),
});
}
async findOneByIdOrThrow(id: string, db = this.drizzle.db) {
const userRole = await this.findOneById(id, db);
if (!userRole) throw Error('User not found');
return userRole;
}
async findAllByUserId(userId: string, db = this.drizzle.db) {
return db.query.user_roles_table.findMany({
where: eq(user_roles_table.user_id, userId),
});
}
async create(data: CreateUserRole, db = this.drizzle.db) {
return db.insert(user_roles_table).values(data).returning().then(takeFirstOrThrow);
}
async delete(id: string, db = this.drizzle.db) {
return db.delete(user_roles_table).where(eq(user_roles_table.id, id)).returning().then(takeFirstOrThrow);
}
}

View file

@ -0,0 +1,51 @@
import type { Transaction } from '../common/utils/drizzle';
import { RolesService } from '../roles/roles.service';
import { type CreateUserRole, UserRolesRepository } from './user_roles.repository';
import { inject, injectable } from '@needle-di/core';
@injectable()
export class UserRolesService {
constructor(
private userRolesRepository = inject(UserRolesRepository),
private rolesService = inject(RolesService),
) {}
async findOneById(id: string) {
return this.userRolesRepository.findOneById(id);
}
async findAllByUserId(userId: string) {
return this.userRolesRepository.findAllByUserId(userId);
}
async create(data: CreateUserRole) {
return this.userRolesRepository.create(data);
}
async addRoleToUser(userId: string, roleName: string, primary = false, trx: Transaction | null = null) {
// Find the role by its name
const role = await this.rolesService.findOneByNameOrThrow(roleName);
if (!role || !role.id) {
throw new Error(`Role with name ${roleName} not found`);
}
if (!trx) {
return this.userRolesRepository.create({
user_id: userId,
role_id: role.id,
primary,
});
}
// Create a UserRole entry linking the user and the role
return this.userRolesRepository.create(
{
user_id: userId,
role_id: role.id,
primary,
},
trx,
);
}
}

View file

@ -37,4 +37,8 @@ export class UsersRepository extends DrizzleRepository {
async create(data: Create, db = this.drizzle.db) { async create(data: Create, db = this.drizzle.db) {
return db.insert(users_table).values(data).returning().then(takeFirstOrThrow); return db.insert(users_table).values(data).returning().then(takeFirstOrThrow);
} }
async delete(id: string, db = this.drizzle.db) {
return db.delete(users_table).where(eq(users_table.id, id)).returning().then(takeFirstOrThrow);
}
} }

View file

@ -1,13 +1,23 @@
import { inject, injectable } from '@needle-di/core'; import { inject, injectable } from '@needle-di/core';
import { UsersRepository } from './users.repository'; import { TokensService } from '../common/services/tokens.service';
import type { UpdateUserDto } from './dtos/update-user.dto'; import { DrizzleService } from '../databases/postgres/drizzle.service';
import { StorageService } from '../storage/storage.service'; import { StorageService } from '../storage/storage.service';
import { CredentialsRepository } from './credentials.repository';
import type { UpdateUserDto } from './dtos/update-user.dto';
import { CredentialsType } from './tables/credentials.table';
import { UsersRepository } from './users.repository';
import { UserRolesService } from './user_roles.service';
import { RoleName } from '../roles/tables/roles.table';
@injectable() @injectable()
export class UsersService { export class UsersService {
constructor( constructor(
private drizzleService = inject(DrizzleService),
private credentialsRepository = inject(CredentialsRepository),
private usersRepository = inject(UsersRepository), private usersRepository = inject(UsersRepository),
private storageService = inject(StorageService) private userRoleService = inject(UserRolesService),
private storageService = inject(StorageService),
private tokenService = inject(TokensService)
) {} ) {}
async update(userId: string, updateUserDto: UpdateUserDto) { async update(userId: string, updateUserDto: UpdateUserDto) {
@ -17,7 +27,68 @@ export class UsersService {
} }
} }
async create(email: string) { async createEmail(email: string) {
return this.usersRepository.create({ avatar: null, email }); return this.usersRepository.create({ avatar: null, email });
} }
async createEmailPassword(email: string, password: string) {
const hashedPassword = await this.tokenService.createHashedToken(password);
return await this.drizzleService.db.transaction(async (trx) => {
const createdUser = await this.usersRepository.create(
{ email, avatar: null },
trx,
);
if (!createdUser) {
return null;
}
const credentials = await this.credentialsRepository.create(
{
user_id: createdUser.id,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
},
trx,
);
if (!credentials) {
await trx.rollback();
return null;
}
await this.userRoleService.addRoleToUser(createdUser.id, RoleName.USER, true, trx);
return createdUser;
});
}
async updatePassword(userId: string, password: string) {
const hashedPassword = await this.tokenService.createHashedToken(password);
const currentCredentials = await this.credentialsRepository.findPasswordCredentialsByUserId(userId);
if (!currentCredentials) {
await this.credentialsRepository.create({
user_id: userId,
type: CredentialsType.PASSWORD,
secret_data: hashedPassword,
});
} else {
await this.credentialsRepository.update(currentCredentials.id, {
secret_data: hashedPassword,
});
}
}
async verifyPassword(userId: string, data: { password: string }) {
const user = await this.usersRepository.findOneById(userId);
if (!user) {
throw new Error('User not found');
}
const credential = await this.credentialsRepository.findOneByUserIdAndType(userId, CredentialsType.PASSWORD);
if (!credential) {
throw new Error('Password credentials not found');
}
const { password } = data;
return this.tokenService.verifyHashedToken(credential.secret_data, password);
}
} }

View file

@ -1,41 +1,43 @@
import type { InferRequestType } from 'hono'; import { parseClientResponse } from '$lib/utils/api';
import { parseApiResponse } from '$lib/utils/api';
import type { Api, ApiMutation } from '$lib/utils/types'; import type { Api, ApiMutation } from '$lib/utils/types';
import { TanstackQueryModule } from './query-module'; import type { InferRequestType } from 'hono';
import { TanstackRequestOptions } from '../request-options';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Types */ /* Types */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
type RequestUsernamePasswordLogin = Api['iam']['login']['$post']; export type RequestUsernamePasswordLogin = Api['iam']['login']['$post'];
type RequestLogin = Api['iam']['login']['request']['$post']; export type RequestLogin = Api['iam']['login']['request']['$post'];
type VerifyLogin = Api['iam']['login']['verify']['$post']; export type VerifyLogin = Api['iam']['login']['verify']['$post'];
type Logout = Api['iam']['logout']['$post']; export type Logout = Api['iam']['logout']['$post'];
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Api */ /* Api */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export class IamModule extends TanstackQueryModule<'iam'> { export class IamModule extends TanstackRequestOptions {
namespace = 'iam';
logout(): ApiMutation<Logout> { logout(): ApiMutation<Logout> {
return { return {
mutationFn: async () => await this.api.iam.logout.$post().then(parseApiResponse) mutationFn: async () => await this.api.iam.logout.$post().then(parseClientResponse)
}; };
} }
requestUsernamePasswordLogin(): ApiMutation<RequestUsernamePasswordLogin> { requestUsernamePasswordLogin(): ApiMutation<RequestUsernamePasswordLogin> {
return { return {
mutationFn: async (data: InferRequestType<RequestUsernamePasswordLogin>) => mutationFn: async (data: InferRequestType<RequestUsernamePasswordLogin>) =>
await this.api.iam.login.$post(data).then(parseApiResponse) await this.api.iam.login.$post(data).then(parseClientResponse)
} }
} }
requestLogin(): ApiMutation<RequestLogin> { requestLogin(): ApiMutation<RequestLogin> {
return { return {
mutationFn: async (data: InferRequestType<RequestLogin>) => mutationFn: async (data: InferRequestType<RequestLogin>) =>
await this.api.iam.login.request.$post(data).then(parseApiResponse) await this.api.iam.login.request.$post(data).then(parseClientResponse)
}; };
} }
verifyLogin(): ApiMutation<VerifyLogin> { verifyLogin(): ApiMutation<VerifyLogin> {
return { return {
mutationFn: async (data: InferRequestType<VerifyLogin>) => mutationFn: async (data: InferRequestType<VerifyLogin>) =>
await this.api.iam.login.verify.$post(data).then(parseApiResponse) await this.api.iam.login.verify.$post(data).then(parseClientResponse)
}; };
} }
} }

View file

@ -1,37 +1,48 @@
import { parseApiResponse } from '$lib/utils/api'; import { parseClientResponse } from '$lib/utils/api';
import type { Api, ApiMutation, ApiQuery } from '$lib/utils/types'; import type { Api, ApiMutation, ApiQuery } from '$lib/utils/types';
import type { InferRequestType } from 'hono'; import type { InferRequestType } from 'hono';
import { TanstackQueryModule } from './query-module'; import { TanstackRequestOptions } from '../request-options';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Types */ /* Types */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
type Me = Api['users']['me']['$get']; export type Me = Api['users']['me']['$get'];
type UpdateEmailRequest = Api['users']['me']['email']['request']['$post']; export type UpdateEmailRequest = Api['users']['me']['email']['request']['$post'];
type VerifyEmailRequest = Api['users']['me']['email']['verify']['$post']; export type VerifyEmailRequest = Api['users']['me']['email']['verify']['$post'];
export type UpdateUser = Api['users']['me']['$patch'];
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Api */ /* Api */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export class UsersModule extends TanstackQueryModule<'users'> { export class UsersModule extends TanstackRequestOptions {
namespace = 'users';
me(): ApiQuery<Me> { me(): ApiQuery<Me> {
return { return {
queryKey: [this.namespace, 'me'], queryKey: [this.namespace, 'me'],
queryFn: async () => await this.api.users.me.$get().then(parseApiResponse) queryFn: async () => await this.api.users.me.$get().then(parseClientResponse)
};
}
update(): ApiMutation<UpdateUser> {
return {
mutationFn: async (args: InferRequestType<UpdateUser>) =>
await this.api.users.me.$patch(args).then(parseClientResponse)
}; };
} }
updateEmailRequest(): ApiMutation<UpdateEmailRequest> { updateEmailRequest(): ApiMutation<UpdateEmailRequest> {
return { return {
mutationFn: async (args: InferRequestType<UpdateEmailRequest>) => mutationFn: async (args: InferRequestType<UpdateEmailRequest>) =>
await this.api.users.me.email.request.$post(args).then(parseApiResponse) await this.api.users.me.email.request.$post(args).then(parseClientResponse)
}; };
} }
verifyEmailRequest(): ApiMutation<VerifyEmailRequest> { verifyEmailRequest(): ApiMutation<VerifyEmailRequest> {
return { return {
mutationFn: async (args: InferRequestType<VerifyEmailRequest>) => mutationFn: async (args: InferRequestType<VerifyEmailRequest>) =>
await this.api.users.me.email.verify.$post(args).then(parseApiResponse) await this.api.users.me.email.verify.$post(args).then(parseClientResponse)
}; };
} }
} }

View file

@ -1,11 +1,11 @@
import { IamModule } from './iam'; import { IamModule } from './domains/iam';
import type { ClientRequestOptions } from 'hono'; import type { ClientRequestOptions } from 'hono';
import { UsersModule } from './users'; import { UsersModule } from './domains/users';
import { TanstackQueryModule } from './query-module'; import { TanstackRequestOptions } from './request-options';
class TanstackQueryHandler extends TanstackQueryModule { class TanstackQueryModule extends TanstackRequestOptions {
iam = new IamModule(this.opts); iam = new IamModule(this.opts);
users = new UsersModule(this.opts); users = new UsersModule(this.opts);
} }
export const queryHandler = (opts?: ClientRequestOptions) => new TanstackQueryHandler(opts); export const api = (opts?: ClientRequestOptions) => new TanstackQueryModule(opts);

View file

@ -1,13 +0,0 @@
import type { ClientRequestOptions } from 'hono';
import { api } from '$lib/utils/api';
export abstract class TanstackQueryModule<T extends string | null = null> {
protected readonly opts: ClientRequestOptions | undefined;
protected readonly api: ReturnType<typeof api>;
public namespace: T | null = null;
constructor(opts?: ClientRequestOptions) {
this.opts = opts;
this.api = api(opts);
}
}

View file

@ -0,0 +1,12 @@
import type { ClientRequestOptions } from 'hono';
import { honoClient } from '$lib/utils/api';
export abstract class TanstackRequestOptions {
protected readonly opts: ClientRequestOptions | undefined;
protected readonly api: ReturnType<typeof honoClient>;
constructor(opts?: ClientRequestOptions) {
this.opts = opts;
this.api = honoClient(opts);
}
}

View file

@ -1,10 +1,10 @@
import type { ApiRoutes } from '$lib/server/api'; import type { ApiRoutes } from '$lib/server/api';
import type { ClientRequestOptions } from 'hono'; import type { ClientRequestOptions } from 'hono';
import { hc, type ClientResponse } from 'hono/client'; import { type ClientResponse, hc } from 'hono/client';
export const api = (options?: ClientRequestOptions) => hc<ApiRoutes>('/', options).api; export const honoClient = (options?: ClientRequestOptions) => hc<ApiRoutes>('/', options).api;
export async function parseApiResponse<T>(response: ClientResponse<T>) { export async function parseClientResponse<T>(response: ClientResponse<T>) {
if (response.ok) { if (response.ok) {
return response.json() as T; return response.json() as T;
} }

View file

@ -1,4 +1,4 @@
import type { api } from '$lib/utils/api'; import type { honoClient } from '$lib/utils/api';
import type { import type {
CreateMutationOptions, CreateMutationOptions,
CreateQueryOptions, CreateQueryOptions,
@ -13,4 +13,4 @@ export type ApiMutation<T> = CreateMutationOptions<
unknown unknown
>; >;
export type ApiQuery<T> = CreateQueryOptions<InferResponseType<T>>; export type ApiQuery<T> = CreateQueryOptions<InferResponseType<T>>;
export type Api = ReturnType<typeof api>; export type Api = ReturnType<typeof honoClient>;

View file

@ -6,28 +6,24 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as Sheet from '$lib/components/ui/sheet/index.js'; import * as Sheet from '$lib/components/ui/sheet/index.js';
import { createMutation } from '@tanstack/svelte-query'; import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { authContext } from '$lib/hooks/session.svelte.js'; import { api } from '$lib/tanstack-query/index.js';
import { queryHandler } from '$lib/tanstack-query/index.js';
import { goto, invalidateAll } from '$app/navigation';
import UserAvatar from '$lib/components/user-avatar.svelte'; import UserAvatar from '$lib/components/user-avatar.svelte';
import ThemeDropdown from '$lib/components/theme-dropdown.svelte'; import ThemeDropdown from '$lib/components/theme-dropdown.svelte';
import { invalidateAll } from '$app/navigation';
const { children, data } = $props(); const { children, data } = $props();
$effect.pre(() => { const queryClient = useQueryClient();
authContext.setAuthedUser(data.authedUser); const authedUserQuery = createQuery(api().users.me());
});
const logoutMutation = createMutation({ const logoutMutation = createMutation({
...queryHandler().iam.logout(), ...api().iam.logout(),
onSuccess: async () => { onSuccess: async () => {
await data.queryClient.invalidateQueries(); await queryClient.invalidateQueries();
invalidateAll(); await invalidateAll();
goto('/login');
} }
}); });
queryHandler;
</script> </script>
<div class="flex min-h-screen w-full flex-col"> <div class="flex min-h-screen w-full flex-col">
@ -86,11 +82,11 @@
</div> </div>
</form> </form>
<ThemeDropdown /> <ThemeDropdown />
{#if !!data.authedUser} {#if !!$authedUserQuery.data}
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Button variant="secondary" size="icon" class="rounded-lg"> <Button variant="secondary" size="icon" class="rounded-lg">
<UserAvatar class="h-8 w-8 rounded-lg" user={data.authedUser} /> <UserAvatar class="h-8 w-8 rounded-lg" user={$authedUserQuery.data} />
<span class="sr-only">Toggle user menu</span> <span class="sr-only">Toggle user menu</span>
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@ -100,8 +96,7 @@
<a class="w-full" href="/settings">Settings</a> <a class="w-full" href="/settings">Settings</a>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item >Logout</DropdownMenu.Item> <DropdownMenu.Item onclick={$logoutMutation.mutate}>Logout</DropdownMenu.Item>
onclick={$logoutMutation.mutate}
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>

View file

@ -1,19 +1,19 @@
<script module lang="ts"> <script module lang="ts">
import { z } from 'zod'; import { z } from 'zod';
export const loginSchema = z.object({ export const loginSchema = z.object({
email: z.string().email(), email: z.string().email(),
}); });
export const loginPasswordSchema = z.object({ export const loginPasswordSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string({ required_error: 'Password is required' }), password: z.string({ required_error: 'Password is required' }),
}) });
export const verifySchema = z.object({ export const verifySchema = z.object({
email: z.string().email(), email: z.string().email(),
code: z.string().length(6) code: z.string().length(6),
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
@ -26,7 +26,7 @@
import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import ChevronLeftIcon from 'lucide-svelte/icons/chevron-left'; import ChevronLeftIcon from 'lucide-svelte/icons/chevron-left';
import { queryHandler } from '$lib/tanstack-query'; import { api } from '$lib/tanstack-query';
import * as InputOTP from '$lib/components/ui/input-otp/index.js'; import * as InputOTP from '$lib/components/ui/input-otp/index.js';
import Separator from '@/components/ui/separator/separator.svelte'; import Separator from '@/components/ui/separator/separator.svelte';
@ -41,19 +41,20 @@
/* ----------------------------------- Api ---------------------------------- */ /* ----------------------------------- Api ---------------------------------- */
const requestUsernamePasswordLoginMutation = createMutation({ const requestUsernamePasswordLoginMutation = createMutation({
...queryHandler().iam.requestUsernamePasswordLogin(), ...api().iam.requestUsernamePasswordLogin(),
onSuccess(_data, variables, _context) { onSuccess(_data, variables, _context) {
step = 'totp'; step = 'totp';
$loginPasswordForm.email = variables.json.email; $loginPasswordForm.email = variables.json.email;
$loginPasswordForm.password = variables.json.password; $loginPasswordForm.password = variables.json.password;
}, },
onError(error) { onError(error) {
loginPasswordErrors.set({ email: [error.message] }); const { message } = JSON.parse(error.message);
loginPasswordErrors.set({ email: [message] });
} }
}) })
const requestMutation = createMutation({ const requestMutation = createMutation({
...queryHandler().iam.requestLogin(), ...api().iam.requestLogin(),
onSuccess(_data, variables, _context) { onSuccess(_data, variables, _context) {
step = 'verify'; step = 'verify';
$verifyForm.email = variables.json.email; $verifyForm.email = variables.json.email;
@ -65,7 +66,7 @@
}); });
const verifyMutation = createMutation({ const verifyMutation = createMutation({
...queryHandler().iam.verifyLogin(), ...api().iam.verifyLogin(),
async onSuccess() { async onSuccess() {
await queryClient.invalidateQueries(); await queryClient.invalidateQueries();
goto('/'); goto('/');

View file

@ -12,8 +12,8 @@
<QueryClientProvider client={data.queryClient}> <QueryClientProvider client={data.queryClient}>
<ParaglideJS {i18n}> <ParaglideJS {i18n}>
<ModeWatcher /> <ModeWatcher />
<main> <main class="antialiased">
{@render children()} {@render children?.()}
</main> </main>
<SvelteQueryDevtools /> <SvelteQueryDevtools />
</ParaglideJS> </ParaglideJS>