Accueil / Articles

Ajout de PostgreSQL, Drizzle et Auth à un monorepo Nest.js, tRPC et Turborepo

Étendez un monorepo TypeScript sécurisé par le type avec Docker Postgres, des schémas drizzle-orm, des modules Nest.js pour les utilisateurs et les articles, des routeurs d’authentification JWT et un formulaire de connexion React.

Éditeur : REACTAPP.TOP13 min de lecture3002 mots

Sources: My monorepo setup to ship full-stack, end-to-end type-safe apps with TypeScript, part 2

Ce est la deuxième étape de la mise en place d’un monorepo pour un TypeScript sécurisé sur toute la chaîne de développement. La première étape comprenait un backend Nest.js exposant des procédures tRPC, ainsi que deux interfaces utilisateur : une application single-page avec React Router et Vite, et un site Astro, tous gérés par Turborepo. Ici, une base de données PostgreSQL est intégrée via drizzle-orm, accompagnée d’une authentification de base et de procédures tRPC privées qui ne permettent de lire ou de créer des données qu’aux utilisateurs connectés. Alors que le monorepo disposait auparavant d’une API et de deux interfaces utilisateur, il dispose maintenant également d’une base de données située derrière l’API.

Une copie complète du code se trouve sur la branche database-and-auth du répertoire monorepo-setup.

Démarrer PostgreSQL avec Docker Compose

La base de données est démarrée à partir d’un fichier compose.yaml situé dans le répertoire racine du monorepo :

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

Lors de son exécution, il télécharge l’image PostgreSQL 17 Alpine si elle n’est pas en cache, crée un conteneur nommé monorepo-setup-database-container, expose le port 5432 sur localhost et stocke les données dans un volume Docker nommé. Le conteneur a besoin d’un fichier .env.database pour démarrer correctement, par exemple :

# apps/api/.env.database
POSTGRES_DB=monorepo-setup
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres

Faites remonter le conteneur avec :

docker compose up -d --remove-orphans

Ensuite, confirmez qu’il est vivant :

docker ps

Un conteneur fonctionnel doit apparaître dans la liste. Pour vérifier qu’une base de données vide a été créée, ouvrez une session psql à l’intérieur du conteneur :

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)

La commande \dt liste les tables, et pour l’instant il n’y en a aucune, ce qui est l’état attendu.

Installation et configuration de drizzle-orm

Tout ce qui suit s’effectue dans l’application api, donc allez dans son dossier :

# From the root of the monorepo
cd apps/api

Installez les dépendances de runtime et de développement :

npm i drizzle-orm@rc pg dotenv
npm i -D drizzle-kit@rc tsx @types/pg

Le fichier .env de l’API nécessite une variable DATABASE_URL ayant cette structure :

DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>

Remplissez l’utilisateur, le mot de passe, l’hôte et le port provenant de la configuration de compose, ainsi que le nom de la base de données depuis POSTGRES_DB. Avec les valeurs ci-dessus, le résultat est :

DATABASE_URL=postgres://postgres:postgres@localhost:5432/monorepo-setup

Nest.js exige que drizzle soit encapsulé dans un module, il faut donc en générer un à l’aide de la CLI :

nest g res drizzle

Après suppression du modèle généré, seuls quelques fichiers restent. Un fichier de constantes contient le token d’injection :

// apps/api/src/drizzle/constants.ts
export const DRIZZLE_ASYNC_PROVIDER = "DRIZZLE_ASYNC_PROVIDER";

Le fournisseur crée le pool de connexions et l’instance de 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 });
  },
};

Une table des utilisateurs suit :

// 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(),
});

Et voici la table des publications :

// 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(),
});

Un fichier de schéma racine réexporte les deux tables :

// apps/api/src/drizzle/schema/index.ts

export * from "./posts";
export * from "./users";

Les IDs sont des ULIDs, donc le paquet Node correspondant doit être installé dans l’API :

npm i ulid

La relation entre les tables est un-à-plusieurs. Un utilisateur, l’auteur, peut écrire de nombreux posts, tandis que chaque post appartient à exactement un utilisateur.

La première partie a présenté un module Env pour des variables d’environnement sécurisées par type, donc la nouvelle DATABASE_URL doit être ajoutée au schéma enregistré :

// 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>;

Dernier élément de configuration : un drizzle.config.ts situé dans la racine de l’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!,
  },
});

Cela indique à drizzle d’écrire les migrations dans un dossier migrations situé à l’intérieur du répertoire du module drizzle et de lire le schéma racine. L’exécution des migrations fait appel à deux scripts présents dans package.json de l’API :

// apps/api/package.json

{
  "scripts": {
    ...
    "generate": "npx drizzle-kit generate",
    "migrate": "npx drizzle-kit migrate",
    ...
  }
}

Créez d’abord la migration, qui écrit un fichier SQL dans le dossier migrations :

npm run generate

Ensuite, appliquez-le à la base de données :

npm run generate

Les tables des utilisateurs et des publications devraient maintenant exister. Vérifiez-les avec la même session psql que précédemment :

docker exec -it monorepo-setup-database-container psql -U postgres -d monorepo-setup

suivi du tableau indiquant la commande :

\dt

Drizzle Studio est une alternative graphique, rendue possible par un script supplémentaire dans package.json:

// apps/api/package.json

{
  "scripts": {
    ...
   "studio": "npx drizzle-kit studio"
    ...
  }
}

Commencez par :

npm run studio

La base de données est maintenant connectée à l’API via Drizzle-ORM. À titre de référence, le fichier drizzle.module.ts doit ressembler à ceci :

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 {}

Et 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 {}

Modules utilisateurs et publications

Ces deux ressources disposent d’un module ainsi que d’un service avec des méthodes CRUD de base. Pour les utilisateurs, générer la ressource depuis le dossier api :

nest g res users

Lorsque le code générique a été supprimé, le service contient la logique :

// 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;
  }
}

Un fichier DTO définit les schémas et les types Zod pour les entrées de chaque méthode :

// 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>;

Le hachage des mots de passe nécessite bcrypt :

# apps/api

npm i bcrypt
npm i -D @types/bcrypt

Le module utilisateurs doit importer le module 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 {}

Les publications suivent le même schéma, en commençant par la génération :

nest g res posts

Après le nettoyage, le service des publications ressemble à ceci :

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;
  }
}

Fichier DTO correspondant :

// 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;

Et le module, qui importe lui aussi le module drizzle, tout comme le fait le module users :

// 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 {}

Authentification

Un module d’authentification propose des méthodes de connexion et d’inscription de base, et un routeur les expose sous forme de procédures que le client peut appeler. Générez-le d’abord :

nest g res auth

Le service contient la logique principale :

// 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,
    };
  }
}

Le fichier DTO valide les données d’entrée :

// 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>;

Le routeur définit les procédures tRPC réelles :

// 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,
            });
          }),
      }),
    };
  }
}

Finalement, le module relie tout cela :

// 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 {}

Avoir des procédures dans auth.router.ts ne suffit pas, car elles doivent également être enregistrées sur le routeur tRPC principal. Mettez à jour 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(),
  });

Le fichier trpc.module.ts a également besoin du module d’authentification :

// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule],
  providers: [TrpcService, TrpcRouter, JwtService, EnvService, AuthRouter],
  exports: [TrpcService],
})

Les posts reçoivent également leurs procédures de la même manière, via 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 }),
          ),
      }),
    };
  }
}

Ces éléments doivent également être enregistrés sur le routeur 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(),
  });

Et le PostsModule doit être importé dans le TrpcModule :

// apps/api/src/trpc/trpc.module.ts

@Module({
  imports: [AuthModule, PostsModule],
  providers: [
    TrpcService,
    TrpcRouter,
    JwtService,
    EnvService,
    AuthRouter,
    PostsRouter,
  ],
  exports: [TrpcService],
})

L’interface utilisateur

Avec l’API en place, l’attention se porte sur le tableau de bord, React Router et l’application Vite. Celle-ci dispose de cinq routes : /login, /register, /posts, /posts/:slug et /posts/create. Un visiteur peut s’inscrire et se connecter, puis créer et lire ses propres articles, et chacune de ces actions appelle une procédure définie dans l’API. Le formulaire de connexion montre à quoi cela ressemble en code :

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>
  );
};

À partir de là, la configuration peut s’étendre autant que nécessaire. De nouvelles procédures côté backend apparaissent côté frontend avec une sécurité de type complète, et c’est là l’objectif principal de toute cette architecture.