Hinzufügen von PostgreSQL, Drizzle und Auth zu einem Monorepo aus Nest.js, tRPC und Turborepo
Erweitern Sie ein typsicheres TypeScript-Monorepo mit Docker Postgres, drizzle-orm-Schemata, Nest.js-Modulen für Benutzer und Beiträge, JWT-Authentifizierungs-Routern sowie einem React-Anmeldeformular.
Quellen: My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript, part 2
Das ist die zweite Phase der Einrichtung eines Monorepos für typsicheren TypeScript von Anfang bis Ende. In der ersten Phase gab es einen Nest.js-Backend, der tRPC-Prozeduren bereitstellte, sowie zwei Frontends – eine React Router- und eine Vite-einseitige Anwendung sowie eine Astro-Seite –, alles orchestriert durch Turborepo. Hier wird eine PostgreSQL-Datenbank über drizzle-orm eingeführt, zusammen mit einer grundlegenden Authentifizierung sowie privaten tRPC-Prozeduren, die nur für eingeloggte Benutzer Daten lesen und erstellen können. Während das Monorepo zuvor eine API und zwei Frontends hatte, verfügt es nun auch über eine Datenbank, die hinter der API liegt.
Eine vollständige Kopie des Codes befindet sich auf der database-and-auth-Branch des monorepo-setup-Repositories.
Starten von PostgreSQL mit Docker Compose
Die Datenbank wird über eine compose.yaml-Datei im Wurzelverzeichnis des Monorepos gestartet:
name: monorepo-setup
services:
db:
image: postgres:17-alpine
container_name: monorepo-setup-database-container
ports:
- 127.0.0.1:5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -d ${POSTGRES_DB} -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- monorepo-setup:/var/lib/postgresql/data
env_file:
- apps/api/.env.database
volumes:
monorepo-setup:
name: monorepo-setup
Beim Ausführen wird das PostgreSQL 17 Alpine-Bild heruntergeladen, falls es nicht im Cache vorhanden ist, ein Container mit dem Namen monorepo-setup-database-container erstellt, Port 5432 auf localhost veröffentlicht und die Daten in einem benannten Docker-Volume speichert. Der Container benötigt eine .env.database-Datei, um ordnungsgemäß zu starten, zum Beispiel:
# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
Führen Sie den Container mit folgendem Befehl hoch:
docker compose up -d --remove-orphans
Dann bestätigen Sie, dass es lebt:
docker ps
In der Auflistung sollte ein gesunder Container erscheinen. Um zu überprüfen, ob eine leere Datenbank erstellt wurde, öffnen Sie eine psql-Sitzung innerhalb des Containers:
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
# monorepo-setup-database-container is the name of the container, as specified in the compose.yaml file
# -U postgres (POSTGRES_USER specified in the .env.database file)
# same for -d monorepo-setup (POSTGRES_DB)
Der Befehl \dt listet Tabellen auf, und derzeit gibt es keine, was der erwartete Zustand ist.
Installieren und konfigurieren von drizzle-orm
Alles, was von hier an geschieht, findet in der api App statt, also wechseln Sie in deren Verzeichnis:
# From the root of the monorepo
cd apps/api
Installieren Sie die Laufzeit- und Entwicklungsabhängigkeiten:
npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg
Die .env-Datei der API benötigt eine DATABASE_URL-Variable in dieser Struktur:
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>
Füllen Sie den Benutzernamen, das Passwort, den Host und die Port-Nummer aus der Konfiguration ein sowie den Datenbanknamen aus POSTGRES_DB. Mit den oben genannten Werten ergibt sich folgendes Ergebnis:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup
Nest.js verlangt, dass drizzle in einem Modul verpackt ist, daher generieren Sie eines mit der CLI:
nest g res drizzle
Nach dem Löschen des generierten Boilerplates bleiben nur noch wenige Dateien übrig. Eine Konstantendatei enthält den Injection-Token:
// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";
Der Anbieter erstellt den Verbindungspool und die drizzle-Instanz:
// apps/api/drizzle/providers/drizzle.provider.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { EnvService } from "@/env/env.service";
import { DRIZZLE_ASYNC_PROVIDER } from "../constants";
export const drizzleProvider = {
provide: DRIZZLE_ASYNC_PROVIDER,
inject: [EnvService],
useFactory: async (envService: EnvService) => {
const connectionString = envService.get("DATABASE_URL");
const pool = new Pool({
connectionString,
});
return drizzle({ client: pool });
},
};
Danach folgt eine Benutzer-Tabelle:
// apps/api/src/drizzle/schema/users.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";
export const users = pgTable("users", {
id: varchar("id", { length: 26 })
.primaryKey()
.notNull()
.$defaultFn(() => ulid().toLowerCase()),
username: text("username").notNull().unique(),
email: text("email").notNull().unique(),
hashedPassword: text("hashed_password").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
Und folgt eine Tabellenanzeige der Beiträge:
// apps/api/src/drizzle/schema/posts.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { timestamp } from "drizzle-orm/pg-core";
import { ulid } from "ulid";
import { users } from "./users";
export const posts = pgTable("posts", {
id: varchar("id", { length: 26 })
.primaryKey()
.notNull()
.$defaultFn(() => ulid().toLowerCase()),
authorId: varchar("author_id", { length: 26 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
slug: text("slug").notNull(),
content: text("content").notNull(),
publishedAt: timestamp("published_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
Eine Wurzelschema-Datei exportiert beide Tabellen erneut:
// apps/api/src/drizzle/schema/index.ts
export * from "./posts";
export * from "./users";
Die IDs sind ULIDs, daher muss das entsprechende Node-Paket in der API installiert werden:
npm i ulid
Die Beziehung zwischen den Tabellen ist eins-zu-viel. Ein Benutzer, der Autor, kann viele Beiträge schreiben, während jeder Beitrag genau einem Benutzer gehört.
Im ersten Teil wurde ein Env-Modul für typsichere Umgebungsvariablen vorgestellt, daher muss die neue DATABASE_URL zum registrierten Schema hinzugefügt werden:
// apps/api/src/env.ts
import { z } from "zod";
export const envSchema = z.object({
NODE_ENV: z.enum(["production", "development"]),
JWT_SECRET: z.string().trim().min(1),
DASHBOARD_URL: z.url(),
WEB_APP_URL: z.url(),
DATABASE_URL: z.string().trim().min(1),
});
export type Env = z.infer<typeof envSchema>;
Der letzte Konfigurationsbestandteil ist eine drizzle.config.ts im API-Root:
// apps/api/drizzle.config.ts
import { defineConfig } from "drizzle-kit";
import "dotenv/config";
export default defineConfig({
out: "./src/drizzle/migrations",
schema: "./src/drizzle/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Es weist Drizzle an, Migrations in ein migrations-Verzeichnis innerhalb des Drizzle-Modulverzeichnisses zu schreiben und das Root-Schema zu lesen. Das Ausführen von Migrations erfordert zwei Skripte in package.json der API:
// apps/api/package.json
{
"scripts": {
...
"generate": "npx drizzle-kit generate",
"migrate": "npx drizzle-kit migrate",
...
}
}
Erstellen Sie zunächst die Migration, die eine SQL-Datei in den Migrationsordner schreibt:
npm run generate
Dann wenden Sie es auf die Datenbank an:
npm run generate
Sowohl die Tabellen für Benutzer als auch für Beiträge sollten jetzt vorhanden sein. Überprüfen Sie sie mit derselben psql-Sitzung wie zuvor:
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
folgt die Tabelle mit der Aufzählung der Befehle:
\dt
Drizzle Studio ist eine grafische Alternative, die durch ein weiteres Skript in package.json aktiviert wird:
// apps/api/package.json
{
"scripts": {
...
"studio": "npx drizzle-kit studio"
...
}
}
Beginnen Sie damit:
npm run studio
Die Datenbank ist nun über Drizzle-ORM mit der API verbunden. Zur Referenz sollte drizzle.module.ts wie folgt aussehen:
import { EnvModule } from "@/env/env.module";
import { Module } from "@nestjs/common";
import { DRIZZLE_ASYNC_PROVIDER } from "./constants";
import { drizzleProvider } from "./providers/drizzle.provider";
@Module({
imports: [EnvModule],
providers: [drizzleProvider],
exports: [DRIZZLE_ASYNC_PROVIDER],
})
export class DrizzleModule {}
Und env.module.ts:
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { EnvService } from "./env.service";
@Module({
imports: [ConfigModule],
providers: [EnvService],
exports: [EnvService],
})
export class EnvModule {}
Benutzer- und Beitragsmodule
Sowohl Ressourcen als auch Dienste verfügen über ein Modul sowie Dienstleistungen mit grundlegenden CRUD-Methoden. Für Benutzer wird die Ressource aus dem api-Ordner generiert:
nest g res users
Sobald der Boilerplate entfernt ist, enthält der Service die Logik:
// apps/api/src/users/users.service.ts
import * as bcrypt from "bcrypt";
import { eq, or } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { users } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";
import { CreateUserDtoType, FindByEmailDtoType } from "./users.dto";
@Injectable()
export class UsersService {
private readonly saltRounds = 10;
constructor(
@Inject(DRIZZLE_ASYNC_PROVIDER)
private readonly db: NodePgDatabase,
) {}
async create(createUserDto: CreateUserDtoType) {
const { email, password, username } = createUserDto;
const [existingUserWithSameEmailOrUsername] = await this.db
.select({ email: users.email, username: users.username })
.from(users)
.where(or(eq(users.email, email), eq(users.username, username)))
.limit(1);
if (existingUserWithSameEmailOrUsername) {
if (existingUserWithSameEmailOrUsername.email === email) {
throw new TRPCError({
code: "CONFLICT",
message: "This email is already used",
});
} else {
throw new TRPCError({
code: "CONFLICT",
message: "This username already exists",
});
}
}
const hashedPassword = await bcrypt.hash(password, this.saltRounds);
const [createdUser] = await this.db
.insert(users)
.values({
username,
email,
hashedPassword,
})
.returning({
id: users.id,
email: users.email,
username: users.username,
});
return createdUser;
}
async findByEmail(findByEmailDto: FindByEmailDtoType) {
const { email } = findByEmailDto;
const [user] = await this.db
.select({
id: users.id,
email: users.email,
username: users.username,
hashedPassword: users.hashedPassword,
})
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
return null;
}
return user;
}
}
Eine DTO-Datei definiert die zod-Schemata und Typen für die Eingabe jeder Methode:
// apps/api/src/users/users.dto.ts
import z from "zod";
export const CreateUserDto = z.object({
email: z.email(),
username: z.string().min(1).max(25),
password: z.string().min(1).max(72),
});
export const FindByEmailDto = z.object({
email: z.email(),
});
export type CreateUserDtoType = z.infer<typeof CreateUserDto>;
export type FindByEmailDtoType = z.infer<typeof FindByEmailDto>;
Das Hashen von Passwörtern erfordert bcrypt:
# apps/api
npm i bcrypt
npm i -D @types/bcrypt
Das Benutzermodul muss das drizzle-Modul importieren:
// apps/api/src/users/users.module.ts
import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
@Module({
imports: [DrizzleModule],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Die Beiträge folgen dem gleichen Muster und beginnen mit der Erstellung:
nest g res posts
Nach der Bereinigung sieht der Posts-Dienst so aus:
import { and, eq } from "drizzle-orm";
import { NodePgDatabase } from "drizzle-orm/node-postgres";
import { DRIZZLE_ASYNC_PROVIDER } from "@/drizzle/constants";
import { posts } from "@/drizzle/schema";
import { Inject, Injectable } from "@nestjs/common";
import { TRPCError } from "@trpc/server";
import {
CreatePostDtoType,
FindAllPostsDtoType,
FindOnePostDtoType,
} from "./posts.dto";
@Injectable()
export class PostsService {
constructor(
@Inject(DRIZZLE_ASYNC_PROVIDER)
private readonly db: NodePgDatabase,
) {}
async create(createPostDto: CreatePostDtoType) {
const { title, slug, content, authorId } = createPostDto;
const [existingPostWithSameSlug] = await this.db
.select({ slug: posts.slug })
.from(posts)
.where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
.limit(1);
if (existingPostWithSameSlug) {
throw new TRPCError({
code: "CONFLICT",
message: "A post with that slug already exists",
});
}
const [createdPost] = await this.db
.insert(posts)
.values({ title, slug, content, authorId, publishedAt: new Date() })
.returning({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
authorId: posts.authorId,
});
return createdPost;
}
async findAll(findAllPostsDto: FindAllPostsDtoType) {
const { authorId } = findAllPostsDto;
const postsFetched = await this.db
.select({ title: posts.title, slug: posts.slug, content: posts.content })
.from(posts)
.where(eq(posts.authorId, authorId));
return postsFetched;
}
async findOne(findOnePostDto: FindOnePostDtoType) {
const { authorId, slug } = findOnePostDto;
const [post] = await this.db
.select({
title: posts.title,
slug: posts.slug,
content: posts.content,
})
.from(posts)
.where(and(eq(posts.authorId, authorId), eq(posts.slug, slug)))
.limit(1);
if (!post) {
return null;
}
return post;
}
}
Sein DTO-Datei:
// apps/api/src/posts/posts.dto.ts
import z from "zod";
export const CreatePostDto = z.object({
title: z.string().min(1).max(150),
slug: z.string().min(1).max(150),
content: z.string().min(1),
});
export const FindOnePostDto = z.object({
slug: z.string().min(1).max(150),
});
type AuthorId = { authorId: string };
export type CreatePostDtoType = z.infer<typeof CreatePostDto> & AuthorId;
export type FindAllPostsDtoType = AuthorId;
export type FindOnePostDtoType = z.infer<typeof FindOnePostDto> & AuthorId;
Und das Modul, das wiederum das drizzle-Modul importiert, genauso wie das users-Modul:
// apps/api/src/posts/posts.module.ts
import { DrizzleModule } from "@/drizzle/drizzle.module";
import { Module } from "@nestjs/common";
import { PostsService } from "./posts.service";
@Module({
imports: [DrizzleModule],
providers: [PostsService],
exports: [PostsService],
})
export class PostsModule {}
Authentifizierung
Ein Auth-Modul bietet grundlegende Methoden zum Anmelden und Registrieren, und ein Router stellt sie als Verfahren zur Verfügung, die der Client aufrufen kann. Erstellen Sie es zunächst:
nest g res auth
Der Service enthält die Hauptlogik:
// apps/api/src/auth/auth.service.ts
import * as bcrypt from "bcrypt";
import { JWTDtoType } from "@/common/dto";
import { UsersService } from "@/users/users.service";
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { TRPCError } from "@trpc/server";
import { LoginDtoType, RegisterDtoType } from "./auth.dto";
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly usersService: UsersService,
) {}
async register(registerDto: RegisterDtoType) {
const { username, email, password } = registerDto;
const createdUser = await this.usersService.create({
email,
username,
password,
});
const payload: Pick<JWTDtoType, "sub"> = { sub: createdUser.id };
const token = await this.jwtService.signAsync(payload);
return { accessToken: token };
}
async login(loginDto: LoginDtoType) {
const { email, password } = loginDto;
const user = await this.usersService.findByEmail({ email });
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const isPasswordCorrect = await bcrypt.compare(
password,
user.hashedPassword,
);
if (!isPasswordCorrect) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Incorrect password",
});
}
const payload: Pick<JWTDtoType, "sub"> = { sub: user.id };
const token = await this.jwtService.signAsync(payload);
return {
accessToken: token,
};
}
}
Die DTO-Datei validiert die Eingabe:
// apps/api/src/auth/auth.dto.ts
import z from "zod";
export const RegisterDto = z.object({
username: z.string().min(1).max(25),
email: z.email(),
password: z.string().min(1).max(72),
});
export const LoginDto = z.object({
email: z.email(),
password: z.string().min(1).max(72),
});
export type RegisterDtoType = z.infer<typeof RegisterDto>;
export type LoginDtoType = z.infer<typeof LoginDto>;
Der Router definiert die tatsächlichen tRPC-Prozeduren:
// apps/api/src/auth/auth.router.ts
import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";
import { LoginDto, RegisterDto } from "./auth.dto";
import { AuthService } from "./auth.service";
@Injectable()
export class AuthRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly authService: AuthService,
) {}
private readonly AUTH_COOKIE_NAME = "auth_token";
private readonly COOKIE_MAX_AGE = 2 * 24 * 60 * 60 * 1000; // 2 days
procedures() {
return {
auth: this.trpcService.trpc.router({
register: this.trpcService
.publicProcedure()
.input(RegisterDto)
.mutation(async ({ ctx, input }) => {
const { accessToken } = await this.authService.register(input);
// Set the HTTP-only cookie
ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: this.COOKIE_MAX_AGE,
});
}),
login: this.trpcService
.publicProcedure()
.input(LoginDto)
.mutation(async ({ ctx, input }) => {
const { accessToken } = await this.authService.login(input);
// Set the HTTP-only cookie
ctx.res.cookie(this.AUTH_COOKIE_NAME, accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: this.COOKIE_MAX_AGE,
});
}),
}),
};
}
}
Schließlich verbindet das Modul alles miteinander:
// apps/api/src/auth/auth.module.ts
import { EnvModule } from "@/env/env.module";
import { EnvService } from "@/env/env.service";
import { TrpcService } from "@/trpc/trpc.service";
import { UsersModule } from "@/users/users.module";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthRouter } from "./auth.router";
import { AuthService } from "./auth.service";
@Module({
imports: [
ConfigModule,
UsersModule,
JwtModule.registerAsync({
imports: [EnvModule],
useFactory: async (envService: EnvService) => ({
global: true,
secret: envService.get("JWT_SECRET"),
signOptions: { expiresIn: "2d" },
}),
inject: [EnvService],
}),
],
providers: [AuthService, AuthRouter, TrpcService, EnvService],
exports: [AuthRouter, AuthService],
})
export class AuthModule {}
Es reicht nicht aus, Verfahren in auth.router.ts zu haben, denn sie müssen auch im Haupt- tRPC-Router registriert werden. Aktualisieren Sie trpc.router.ts:
// apps/api/src/trpc/trpc.router.ts
appRouter = this.trpcService.trpc.router({
getHello: this.trpcService
.publicProcedure()
.input(
z.object({
name: z.string().min(1),
}),
)
.query(({ input }) => `Hello, ${input.name} from trpc server`),
...this.authRouter.procedures(),
});
Die Datei trpc.module.ts benötigt auch das Authentifizierungsmodul:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule],
providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
exports: [TrpcService],
})
Posts erhalten Verfahren auf dieselbe Weise, über posts.router.ts:
// apps/api/src/posts/posts.router.ts
import { TrpcService } from "@/trpc/trpc.service";
import { Injectable } from "@nestjs/common";
import { CreatePostDto, FindOnePostDto } from "./posts.dto";
import { PostsService } from "./posts.service";
@Injectable()
export class PostsRouter {
constructor(
private readonly trpcService: TrpcService,
private readonly postsService: PostsService,
) {}
procedures() {
return {
posts: this.trpcService.trpc.router({
create: this.trpcService
.protectedProcedure()
.input(CreatePostDto)
.mutation(async ({ ctx, input }) =>
this.postsService.create({ ...input, authorId: ctx.user.sub }),
),
findAll: this.trpcService
.protectedProcedure()
.query(async ({ ctx }) =>
this.postsService.findAll({ authorId: ctx.user.sub }),
),
findOne: this.trpcService
.protectedProcedure()
.input(FindOnePostDto)
.query(async ({ ctx, input }) =>
this.postsService.findOne({ ...input, authorId: ctx.user.sub }),
),
}),
};
}
}
Auch diese müssen im tRPC-Router registriert werden:
// apps/api/src/trpc/trpc.router.ts
appRouter = this.trpcService.trpc.router({
getHello: this.trpcService
.publicProcedure()
.input(
z.object({
name: z.string().min(1),
}),
)
.query(({ input }) => `Hello, ${input.name} from trpc server`),
...this.authRouter.procedures(),
...this.postsRouter.procedures(),
});
Und der PostsModule muss in den TrpcModule importiert werden:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule, PostsModule],
providers: [
TrpcService,
TrpcRouter,
JwtService,
EnvService,
AuthRouter,
PostsRouter,
],
exports: [TrpcService],
})
Die Frontend-Plattform
Sobald die API vorhanden ist, richtet sich die Aufmerksamkeit auf das Dashboard sowie die React Router- und Vite-Anwendung. Diese umfasst fünf Routen: /login, /register, /posts, /posts/:slug und /posts/create. Ein Besucher kann sich registrieren und anmelden, anschließend eigene Beiträge erstellen und lesen, wobei jede dieser Aktionen eine auf der API definierte Prozedur aufruft. Das Anmeldeformular zeigt, wie das im Code aussieht:
import { Controller, useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { useTRPC } from "@/utils/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoginSchema, type LoginSchemaType } from "@repo/common/types-schemas";
import { Button } from "@repo/ui/components/ui/button";
import { Field, FieldError, FieldLabel } from "@repo/ui/components/ui/field";
import { Input } from "@repo/ui/components/ui/input";
import { useMutation } from "@tanstack/react-query";
import { useTogglePassword } from "../hooks/use-toggle-password";
export const LoginForm = () => {
const form = useForm<LoginSchemaType>({
resolver: zodResolver(LoginSchema),
defaultValues: {
email: "",
password: "",
},
});
const { isPasswordVisible, EyeIconComponent } = useTogglePassword();
const navigate = useNavigate();
const trpc = useTRPC();
const loginMutation = useMutation(trpc.auth.login.mutationOptions());
const onSubmit = (values: LoginSchemaType) => {
loginMutation.mutate(
{
email: values.email,
password: values.password,
},
{
onError: (error) => {
const errorMessage = error.message;
if (errorMessage.toLowerCase().includes("email")) {
form.setError("email", { message: errorMessage });
} else if (errorMessage.toLowerCase().includes("password")) {
form.setError("password", { message: errorMessage });
} else {
form.setError("root", { message: errorMessage });
}
},
onSuccess: () => {
toast.success("Login complete");
navigate("/posts");
},
},
);
};
return (
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-[clamp(40%,30rem,80%)] flex-col gap-4 p-2"
>
<h2 className="flex flex-col items-center justify-center gap-2 text-center text-3xl font-extrabold max-[42.5rem]:text-2xl">
Login
</h2>
<Controller
control={form.control}
name="email"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
placeholder="example@email.com"
type="email"
aria-invalid={fieldState.invalid}
className="border-border h-10"
{...field}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Controller
control={form.control}
name="password"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Password</FieldLabel>
<div className="relative flex items-center justify-end">
<Input
id={field.name}
placeholder="***********"
type={isPasswordVisible ? "text" : "password"}
aria-invalid={fieldState.invalid}
className="border-border h-10"
{...field}
/>
<EyeIconComponent />
</div>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<p>
Not registered yet?{" "}
<Link to="/register" className="underline">
Register
</Link>
</p>
<Button
variant="default"
type="submit"
disabled={form.formState.isSubmitting}
className="h-10 w-1/2 self-center rounded-lg"
>
Submit
</Button>
<div className="h-4">
{form.formState.errors.root && (
<FieldError errors={[form.formState.errors.root]} />
)}
</div>
</form>
);
};
Von hier aus kann die Einrichtung nach Bedarf erweitert werden. Neue Prozeduren im Backend erscheinen im Frontend mit voller Typsicherheit – und das ist der Zweck der gesamten Konfiguration.