Loading apps/api/package.json +5 −0 Original line number Diff line number Diff line Loading @@ -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", Loading @@ -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", Loading @@ -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": { Loading apps/api/prisma/migrations/20250921104700_/migration.sql 0 → 100644 +3 −0 Original line number Diff line number Diff line -- AlterTable ALTER TABLE "public"."User" ADD COLUMN "resetTokenExpiry" TIMESTAMP(3), ADD COLUMN "resetTokenHash" TEXT; apps/api/prisma/schema.prisma +12 −10 Original line number Diff line number Diff line Loading @@ -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[] } Loading @@ -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 { Loading apps/api/src/auth/auth-git.service.ts +77 −51 Original line number Diff line number Diff line Loading @@ -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, Loading @@ -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, Loading apps/api/src/auth/auth.controller.ts +36 −4 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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() Loading @@ -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 Loading
apps/api/package.json +5 −0 Original line number Diff line number Diff line Loading @@ -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", Loading @@ -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", Loading @@ -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": { Loading
apps/api/prisma/migrations/20250921104700_/migration.sql 0 → 100644 +3 −0 Original line number Diff line number Diff line -- AlterTable ALTER TABLE "public"."User" ADD COLUMN "resetTokenExpiry" TIMESTAMP(3), ADD COLUMN "resetTokenHash" TEXT;
apps/api/prisma/schema.prisma +12 −10 Original line number Diff line number Diff line Loading @@ -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[] } Loading @@ -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 { Loading
apps/api/src/auth/auth-git.service.ts +77 −51 Original line number Diff line number Diff line Loading @@ -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, Loading @@ -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, Loading
apps/api/src/auth/auth.controller.ts +36 −4 Original line number Diff line number Diff line Loading @@ -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) Loading @@ -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() Loading @@ -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