Commit 6f394993 authored by Antonín Kadrmas's avatar Antonín Kadrmas
Browse files

ref (!5): add password reset with email implementation, refactor the git auth...

ref (!5): add password reset with email implementation, refactor the git auth service, add new fileds for password reset into user object
parent d6fddb31
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -7,11 +7,14 @@
    "@nestjs/common": "^10.0.0",
    "@nestjs/config": "^4.0.0",
    "@nestjs/core": "^10.0.0",
    "@nestjs/event-emitter": "^3.0.1",
    "@nestjs/jwt": "^11.0.0",
    "@nestjs/passport": "^11.0.5",
    "@nestjs/platform-express": "^10.0.0",
    "@nestjs/platform-socket.io": "^11.1.6",
    "@nestjs/swagger": "^11.0.3",
    "@nestjs/throttler": "^6.4.0",
    "@nestjs/websockets": "^11.1.6",
    "@prisma/client": "6.3.1",
    "@repo/shared": "0.0.0",
    "@ts-rest/nest": "^3.51.0",
@@ -27,6 +30,7 @@
    "helmet": "^8.0.0",
    "js-yaml": "^4.1.0",
    "kafkajs": "^2.2.4",
    "nodemailer": "^7.0.6",
    "passport": "^0.7.0",
    "passport-github2": "^0.1.12",
    "passport-gitlab2": "^5.0.0",
@@ -35,6 +39,7 @@
    "reflect-metadata": "^0.2.2",
    "rxjs": "^7.8.1",
    "simple-git": "^3.27.0",
    "socket.io": "^4.8.1",
    "zod": "*"
  },
  "devDependencies": {
+3 −0
Original line number Diff line number Diff line
-- AlterTable
ALTER TABLE "public"."User" ADD COLUMN     "resetTokenExpiry" TIMESTAMP(3),
ADD COLUMN     "resetTokenHash" TEXT;
+12 −10
Original line number Diff line number Diff line
@@ -26,6 +26,8 @@ model User {
  id    String  @default(uuid()) @id
  email String  @unique
  passwordHash String
  resetTokenHash String? // Add reset token hash
  resetTokenExpiry DateTime? // Add reset token expiry
  gitAccounts GitAccount[]
  project Project[]
}
@@ -42,7 +44,7 @@ model GitAccount {
  accountId   String
  userId      String
  user        User    @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  @@unique([provider,accountId])
  @@unique([provider, accountId]) // Ensure unique Git account per provider
}

model Project {
+77 −51
Original line number Diff line number Diff line
@@ -15,67 +15,94 @@ export class AuthGitService {
    private readonly authHashService: AuthHashService,
  ) {}

  async signUpGit(
    user: GitlabProfileType | GithubProfileType,
    userId: string | undefined = undefined,
  ): Promise<PayloadType> {
    const { id: accountId, emails, provider } = user.profile;
    const email = emails[0].value;
  async signUpGit(user: GitlabProfileType | GithubProfileType, userId?: string): Promise<PayloadType> {
    const { accountId, provider, emails } = user.profile;
    const email = this._getValidEmail(emails);

    // 1. Check if this Git account is already in DB
    const existingAccount = await this.prismaService.gitAccount.findUnique({
      where: { provider_accountId: { provider, accountId } },
      include: { user: true }
      include: { user: true },
    });

    // 2. If account exists and is allowed to sign in (email match + signIn flag)
    if (!userId && existingAccount) {
      return this._handleExistingAccount(existingAccount);
    }

    if (userId) {
      return this._handleLinkingAccount(user, userId, existingAccount);
    }

    return this._handleNewAccount(user, email);
  }

  // --------------------
  // Private helpers
  // --------------------

  private _getValidEmail(emails: { value: string; primary?: boolean; verified?: boolean }[]): string {
    const emailObj = emails.find((e) => e.primary && e.verified) ?? emails[0];
    if (!emailObj) {
      throw new ForbiddenException("No valid email returned by provider");
    }
    return emailObj.value;
  }

  private async _handleExistingAccount(existingAccount: GitAccount & { user: User }): Promise<PayloadType> {
    if (existingAccount.email === existingAccount.user.email) {
      return this.authTokenService.getSignToken(existingAccount.userId);
    }
      throw new ForbiddenException('Cannot sign in with this Git account');
    throw new ForbiddenException("Cannot sign in with this Git account");
  }

    // 3. If this is a logged-in user linking a new Git account
    if (userId) {
      const gitAccount: GitAccount = this._gitAccountModelCreate(user, userId);
      return this._create(gitAccount).then(a =>
        this.authTokenService.getSignToken(a.userId),
      );
  private async _handleLinkingAccount(
    user: GitlabProfileType | GithubProfileType,
    userId: string,
    existingAccount: (GitAccount & { user: User }) | null,
  ): Promise<PayloadType> {
    if (existingAccount) {
      throw new ConflictException("This Git account is already linked to another user");
    }

    // 4. If no user exists, create new User + GitAccount only if email is valid
    const userRecord = await this.prismaService.user.findUnique({
      where: { email }
    const gitAccount = this._gitAccountModelCreate(user, userId);
    const created = await this._create(gitAccount);

    return this.authTokenService.getSignToken(created.userId);
  }

  private async _handleNewAccount(
    user: GitlabProfileType | GithubProfileType,
    email: string,
  ): Promise<PayloadType> {
    const existingUser = await this.prismaService.user.findUnique({
      where: { email },
    });

    if (!userRecord) {
      // create new user + account
    if (existingUser) {
      // This case happens when someone signed up with email/password
      // but is now trying to log in via Git provider without linking.
      throw new ConflictException("An account with this email exists but is not linked to Git");
    }

    const newUser = await this.prismaService.user.create({
      data: {
        email,
          passwordHash: '', // or null if optional
        passwordHash: null, // make password optional in schema
        gitAccounts: {
          create: {
            ...this._gitAccountModelCreate(user),
            }
          }
        }
          },
        },
      },
    });
      return this.authTokenService.getSignToken(newUser.id);
    }

    throw new ConflictException('Account exists but not linked');
    return this.authTokenService.getSignToken(newUser.id);
  }


  _gitAccountModelCreate(
    user: GitlabProfileType | GithubProfileType,
    userId: string | undefined = undefined,
  ): GitAccount {
  private _gitAccountModelCreate(user: GitlabProfileType | GithubProfileType, userId?: string): GitAccount {
    const { id: accountId, username, provider, profileUrl, emails } = user.profile;
    const { avatar_url, name: displayName } = user.profile._json;
    const email = emails[0].value;

    return {
      id: undefined,
      displayName,
@@ -84,18 +111,17 @@ export class AuthGitService {
      profileUrl,
      photos: avatar_url,
      email,
      userId: userId,
      userId,
      accountId,
      accessToken: user.accessToken,
    };
  }

  async _create(gitAccount: GitAccount): Promise<GitAccount & { user: User }> {
    return await this.prismaService.gitAccount.create({
  private async _create(gitAccount: GitAccount): Promise<GitAccount & { user: User }> {
    return this.prismaService.gitAccount.create({
      include: { user: true },
      data: {
        email: gitAccount.email,
        id: gitAccount.id,
        displayName: gitAccount.displayName,
        username: gitAccount.username,
        provider: gitAccount.provider,
+36 −4
Original line number Diff line number Diff line
@@ -94,6 +94,28 @@ export class AuthController {
    }
  }

  @TsRest(c.postAuthRequestPasswordReset)
  async requestPasswordReset(@TsRestRequest() { body }: RequestShapes["postAuthRequestPasswordReset"]) {
    try {
      await this.authService.requestPasswordReset(body.email);
      return { status: 200 as const, body: "Password reset email sent." };
    } catch (e) {
      console.error(e);
      return { status: 400 as const, body: "Error during password reset request." };
    }
  }

  @TsRest(c.postAuthResetPassword)
  async resetPassword(@TsRestRequest() { body }: RequestShapes["postAuthResetPassword"]) {
    try {
      await this.authService.resetPasswordWithToken(body.email, body.token, body.newPassword);
      return { status: 200 as const, body: "Password reset successful." };
    } catch (e) {
      console.error(e);
      return { status: 400 as const, body: "Error during password reset." };
    }
  }

  @Optional()
  @UseGuards(GithubAuthGuard)
  @TsRest(c.postAuthGithub)
@@ -107,8 +129,13 @@ export class AuthController {
    @CurrentProfile() profile: GithubProfileType,
    @CurrentUser() user?: CurrentUserType,
  ) {
    try {
      const result = await this.authGitService.signUpGit(profile, user?.sub);
      return { status: 200 as const, body: result };
    } catch (e) {
      console.error(e);
      return { status: 400 as const, body: "Error during GitHub authentication." };
    }
  }

  @Optional()
@@ -124,8 +151,13 @@ export class AuthController {
    @CurrentProfile() profile: GitlabProfileType,
    @CurrentUser() user?: CurrentUserType,
  ) {
    try {
      const result = await this.authGitService.signUpGit(profile, user?.sub);
      return { status: 200 as const, body: result };
    } catch (e) {
      console.error(e);
      return { status: 400 as const, body: "Error during GitLab authentication." };
    }
  }

  @Optional()
Loading