Добавление PostgreSQL, Drizzle и Auth в монорепозиторий Nest.js с tRPC и Turborepo
Расширьте типобезопасный монорепозиторий TypeScript с помощью Docker Postgres, схем drizzle-orm, модулей users и posts в Nest.js, маршрутов аутентификации JWT и формы входа в React.
Источники: My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript, part 2
Это второй этап настройки монорепозитория для полностью типобезопасного TypeScript с поддержкой концепции end-to-end. На первом этапе использовался бэкенд Nest.js с интерфейсами tRPC, а также два фронтенда: одностраничное приложение на React Router и Vite, и сайт на Astro, все из которых управлялись через Turborepo. Здесь добавляется база данных PostgreSQL с помощью drizzle-orm, включая механизмы базовой аутентификации и приватные процедуры tRPC, которые позволяют читать и создавать данные только зарегистрированным пользователям. Если раньше в монорепозитории был API и два фронтенда, то теперь за API также находится база данных.
Полная копия кода хранится на ветке database-and-auth репозитория monorepo-setup.
Запуск PostgreSQL с помощью Docker Compose
База данных запускается на основе файла compose.yaml, расположенного в корне монорепозитория:
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
При запуске, если изображение PostgreSQL 17 Alpine не находится в кэше, программа загружает его, создаёт контейнер с именем monorepo-setup-database-container, открывает порт 5432 на локальном хосте и хранит данные в объёме Docker с указанным именем. Для корректной работы контейнеру требуется файл .env.database, например:
# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
Поднимите контейнер с помощью:
docker compose up -d --remove-orphans
Затем подтвердите, что он жив:
docker ps
В списке должен отображаться здоровый контейнер. Чтобы проверить, была ли создана пустая база данных, откройте сессию psql внутри контейнера:
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)
Команда \dt выводит список таблиц, и на данный момент их нет, что является ожидаемым состоянием.
Установка и настройка drizzle-orm
Все дальнейшие действия выполняются в приложении api, поэтому перейдите в его папку:
# From the root of the monorepo
cd apps/api
Установите среду выполнения и зависимости для разработки:
npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg
В файле .env API необходима переменная DATABASE_URL следующего формата:
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>
Укажите имя пользователя, пароль, хост и порт, заданные при настройке, а также имя базы данных из POSTGRES_DB. С использованием приведённых выше значений результат будет следующим:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup
Nest.js требует, чтобы drizzle был упакован в модуль, поэтому сгенерируйте его с помощью CLI:
nest g res drizzle
После удаления сгенерированного шаблонного кода остаётся лишь несколько файлов. В файле с константами хранится токен вставки:
// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";
Провайдер создаёт пул соединений и экземпляр drizzle:
// 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 });
},
};
Далее идет таблица пользователей:
// 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(),
});
А далее следует таблица постов:
// 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(),
});
Файл корневой схемы повторно экспортирует обе таблицы:
// apps/api/src/drizzle/schema/index.ts
export * from "./posts";
export * from "./users";
Идентификаторы являются ULIDs, поэтому соответствующий пакет Node необходимо установить в api:
npm i ulid
Соотношение между таблицами — один к многим. Один пользователь, автор, может написать множество постов, в то время как каждый пост принадлежит ровно одному пользователю.
В первой части был представлен модуль Env для типобезопасных переменных окружения, поэтому новую переменную DATABASE_URL необходимо добавить в зарегистрированную схему:
// 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>;
Последним элементом конфигурации является файл drizzle.config.ts в корне API:
// 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!,
},
});
Он указывает drizzle записывать миграции в папку migrations внутри каталога модуля drizzle и читать корневую схему. Запуск миграций осуществляется с помощью двух скриптов в файле package.json API:
// apps/api/package.json
{
"scripts": {
...
"generate": "npx drizzle-kit generate",
"migrate": "npx drizzle-kit migrate",
...
}
}
Сначала сгенерируйте миграцию, которая создаст файл SQL в папке migrations:
npm run generate
Затем примените его к базе данных:
npm run generate
Теперь должны существовать таблицы как пользователей, так и постов. Проверьте их с помощью той же сессии psql, что и раньше:
docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup
за которым следует таблица с перечнем команд:
\dt
Drizzle Studio — это графическая альтернатива, которая реализуется с помощью ещё одного скрипта в package.json:
// apps/api/package.json
{
"scripts": {
...
"studio": "npx drizzle-kit studio"
...
}
}
Начните с:
npm run studio
Теперь база данных подключена к API с помощью Drizzle-ORM. Для справки файл drizzle.module.ts должен выглядеть следующим образом:
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 {}
А файл 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 {}
Модули пользователей и постов
Для обоих ресурсов создаются модуль и сервис с базовыми методами CRUD. Для пользователей ресурс генерируется из папки api:
nest g res users
После удаления шаблонного кода логика находится в сервисе:
// 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;
}
}
Файл DTO определяет схемы zod и типы для входных данных каждого метода:
// 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>;
Для хеширования паролей требуется bcrypt:
# apps/api
npm i bcrypt
npm i -D @types/bcrypt
Модуль пользователей должен импортировать модуль drizzle:
// 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 {}
Посты следуют тому же шаблону, начинаясь с информации о генерации:
nest g res posts
После очистки сервис публикаций выглядит так:
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;
}
}
Файл DTO:
// 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;
И модуль, который, как и модуль users, импортирует модуль drizzle:
// 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 {}
Аутентификация
Модуль аутентификации предоставляет базовые методы входа и регистрации, а маршрутизатор делает их доступными в виде процедур, которые может вызывать клиент. Сначала необходимо его сгенерировать:
nest g res auth
В этом сервисе реализована основная логика:
// 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,
};
}
}
Файл DTO проверяет входные данные:
// 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>;
Роутер определяет сами процедуры tRPC:
// 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,
});
}),
}),
};
}
}
Наконец, модуль связывает всё воедино:
// 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 {}
Недостаточно иметь процедуры в auth.router.ts, поскольку их также необходимо зарегистрировать в основном маршрутизаторе tRPC. Обновите 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(),
});
Файл trpc.module.ts также требует модуля аутентификации:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule],
providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
exports: [TrpcService],
})
Процедуры для постов применяются аналогичным образом, через 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 }),
),
}),
};
}
}
Их также необходимо зарегистрировать в маршрутизаторе tRPC:
// 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(),
});
И модуль PostsModule необходимо импортировать в TrpcModule:
// apps/api/src/trpc/trpc.module.ts
@Module({
imports: [AuthModule, PostsModule],
providers: [
TrpcService,
TrpcRouter,
JwtService,
EnvService,
AuthRouter,
PostsRouter,
],
exports: [TrpcService],
})
Фронтенд
После создания API внимание переключается на панель управления, React Router и приложение Vite. В нём предусмотрено пять маршрутов: /login, /register, /posts, /posts/:slug и /posts/create. Посетитель может зарегистрироваться и войти, затем создавать и читать собственные посты, причём каждое из этих действий вызывает процедуру, определённую в API. Форма входа показывает, как это выглядит в коде:
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>
);
};
Отсюда структура может развиваться насколько это необходимо. Новые процедуры на серверной части появляются на клиентской части с полной типобезопасностью, и именно в этом заключается смысл всей этой схемы.