Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions apps/api/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuthService, GoogleAuthGuard, JwtAuthGuard, LocalAuthGuard } from '@gh/auth';
import { globalConfig } from '@gh/config';
import { User as PrismaUser } from '@gh/prisma';
import { PrismaService, User as PrismaUser } from '@gh/prisma';
import { AuthKeys } from '@gh/shared/models';
import { loggedMethod } from '@gh/shared/utils';
import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
Expand All @@ -14,8 +14,15 @@ export class AuthController {
constructor(
@Inject(globalConfig.KEY) private readonly config: ConfigType<typeof globalConfig>,
private readonly authService: AuthService,
private readonly prismaService: PrismaService,
) {}

@Get('connected')
@loggedMethod('Check if the server is connected to the database')
async connected(@Req() req: any, @Res() res: Response<boolean>) {
res.send(this.prismaService.isConnected);
}

@UseGuards(LocalAuthGuard)
@Post('login')
@HttpCode(HttpStatus.OK)
Expand Down Expand Up @@ -64,11 +71,16 @@ export class AuthController {
async googleLoginCallback(@Req() req: any, @Res() res: Response) {
console.log('*** AuthController / googleLoginCallback, req.user = ', req.user);
if (req.user?.email) {
const response = await this.authService.login(req.user);

res.cookie(AuthKeys.AccessToken, response.accessToken, { secure: true });
res.cookie(AuthKeys.RefreshToken, response.refreshToken, { secure: true });
res.redirect(this.config.webApp.url as string);
try {
const response = await this.authService.login(req.user);

res.cookie(AuthKeys.AccessToken, response.accessToken, { secure: true });
res.cookie(AuthKeys.RefreshToken, response.refreshToken, { secure: true });
res.redirect(this.config.webApp.url as string);
} catch (error) {
console.error('Error during Google login callback:', error);
res.redirect(`${this.config.webApp.url}/login?error=google`);
}
} else {
res.redirect(`${this.config.webApp.url}/login`);
}
Expand Down
20 changes: 10 additions & 10 deletions apps/api/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"],
"emitDecoratorMetadata": true,
"target": "es2021"
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"],
"emitDecoratorMetadata": true,
"target": "es2021"
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}
3 changes: 3 additions & 0 deletions apps/ui-e2e/src/gh-users/gh-users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { GhUserRepoPage, LoginPage } from '../poms';
import { addTokenCookies, EMAIL, ghRepoContributorsMock, ghRepoLanguagesMock, ghUserMock, ghUserReposMock, ghUsersMock, PASSWORD } from '../utils';

const loginSuccessfully = async (page: Page, loginPage: LoginPage) => {
await page.route(/\/auth\/connected$/, async (route) => {
await route.fulfill({ status: 200, body: 'true' });
});
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();
Expand Down
61 changes: 43 additions & 18 deletions apps/ui-e2e/src/login/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,54 @@ import { expect } from '@playwright/test';
import { test } from '../fixtures';
import { addTokenCookies, EMAIL, PASSWORD } from '../utils';

test('login with wrong user and password should display an error message', async ({ page, loginPage }) => {
await page.route(/\/auth\/login$/, async (route) => {
await route.fulfill({ status: 401 });

test.describe('when database connection fails', () => {
test.beforeEach(async ({ page }) => {
await page.route(/\/auth\/connected$/, async (route) => {
await route.fulfill({ status: 200, body: 'false' });
});
});
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();

await expect(loginPage.error).toBeVisible();
test('should display an error message', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();

await expect(loginPage.databaseConnectionError).toBeVisible();
});
});

test('login with correct user and password should navigate to users page', async ({ page, loginPage, context }) => {
await page.route(/\/auth\/login$/, async (route) => {
await addTokenCookies(context);
await route.fulfill({ status: 200 });
test.describe('when database connection succeeds', () => {
test.beforeEach(async ({ page }) => {
await page.route(/\/auth\/connected$/, async (route) => {
await route.fulfill({ status: 200, body: 'true' });
});
});
await page.route(/\/github\/users\?/, async (route) => {
await route.fulfill({ json: [], status: 200 });

test('login with wrong user and password should display an error message', async ({ page, loginPage }) => {
await page.route(/\/auth\/login$/, async (route) => {
await route.fulfill({ status: 401 });
});
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();

await expect(loginPage.inavlidCredentialsError).toBeVisible();
});
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();
await page.waitForResponse(/\/github\/users\?/);

await expect(page.url()).toMatch(/users$/);
test('login with correct user and password should navigate to users page', async ({ page, loginPage, context }) => {
await page.route(/\/auth\/login$/, async (route) => {
await addTokenCookies(context);
await route.fulfill({ status: 200 });
});
await page.route(/\/github\/users\?/, async (route) => {
await route.fulfill({ json: [], status: 200 });
});
await loginPage.goto();
await loginPage.fill(EMAIL, PASSWORD);
await loginPage.login();
await page.waitForResponse(/\/github\/users\?/);

await expect(page.url()).toMatch(/users$/);
});
});
6 changes: 4 additions & 2 deletions apps/ui-e2e/src/poms/login.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ export class LoginPage {
readonly password: Locator;
readonly loginUserPassowrd: Locator;
readonly spinner: Locator;
readonly error: Locator;
readonly databaseConnectionError: Locator;
readonly inavlidCredentialsError: Locator;

constructor(page: Page) {
this.page = page;
this.email = this.page.getByTestId('email');
this.password = this.page.getByTestId('password');
this.loginUserPassowrd = this.page.getByRole('button', { name: /submit/i });
this.spinner = this.page.getByTestId('spinner');
this.error = this.page.getByTestId('invalidCredentials');
this.databaseConnectionError = this.page.getByTestId('dbConnectionError');
this.inavlidCredentialsError = this.page.getByTestId('invalidCredentials');
}

async goto() {
Expand Down
10 changes: 7 additions & 3 deletions apps/ui/src/app/components/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@ <h4 class="card-title pb-3">Login with email and password</h4>
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" formControlName="password" placeholder="Password" data-testid="password">
</div>
<div class="mb-3">
<div class="invalid-feedback" [class.d-block]="!validCredentials()" data-testid="invalidCredentials">Wrong credentials</div>
</div>
<button type="submit" class="btn btn-primary" id="submitUserPassword" [disabled]="!loginForm.valid">Submit</button>
@if (loading()) {
<div class="spinner-border spinner-border-sm ms-4" role="status" data-testid="spinner"></div>
Expand All @@ -32,3 +29,10 @@ <h4 class="card-title pb-3">Login with Google</h4>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="invalid-feedback ps-3" [class.d-block]="connectionError()" data-testid="dbConnectionError">Database connection error</div>
<div class="invalid-feedback ps-3" [class.d-block]="serverError()" data-testid="serverError">Server error</div>
<div class="invalid-feedback ps-3" [class.d-block]="!validCredentials()" data-testid="invalidCredentials">Wrong credentials</div>
</div>
</div>
44 changes: 32 additions & 12 deletions apps/ui/src/app/components/login/login.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,50 @@ export class LoginComponent implements OnInit {
email: new FormControl('', Validators.required),
password: new FormControl('', Validators.required),
});
validCredentials = signal(true);
loading = signal(false);
protected serverError = this.#authService.serverError;
protected connectionError = signal(false);
protected validCredentials = signal(true);
protected loading = signal(false);

ngOnInit(): void {
this.#authService.logout();
}

#isConnected() {
return firstValueFrom(this.#authService.isConnected());
}

async submit() {
console.log(this.loginForm.value);
const login$ = this.#authService.login(this.loginForm.value.email as string, this.loginForm.value.password as string);

this.connectionError.set(false);
this.validCredentials.set(true);
this.loading.set(true);

await firstValueFrom(login$);
this.loading.set(false);
if (this.#authService.authenticated) {
await this.#router.navigateToUsers();
if (await this.#isConnected()) {
const login$ = this.#authService.login(this.loginForm.value.email as string, this.loginForm.value.password as string);

this.validCredentials.set(true);
this.loading.set(true);

await firstValueFrom(login$);
this.loading.set(false);
if (this.#authService.authenticated) {
await this.#router.navigateToUsers();
} else if (!this.serverError()) {
this.validCredentials.set(false);
}
} else {
this.validCredentials.set(false);
this.connectionError.set(true);
}
}

goToGoogleLogin() {
this.#authService.loginGoogle();
async goToGoogleLogin() {
this.connectionError.set(false);
this.validCredentials.set(true);

if (await this.#isConnected()) {
this.#authService.loginGoogle();
} else {
this.connectionError.set(true);
}
}
}
30 changes: 26 additions & 4 deletions apps/ui/src/app/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { AuthKeys } from '@gh/shared/models';
import { loggedMethod } from '@gh/shared/utils';
import { CookieService } from 'ngx-cookie-service';
import { catchError, of, tap } from 'rxjs';
import { StoreService } from 'services/store.service';
import { publicPost, refreshPost } from 'utils/api';
import { publicGet, publicPost, refreshPost } from 'utils/api';

type Credentials = {
accessToken: string;
Expand All @@ -20,6 +20,12 @@ export class AuthService {
readonly #http = inject(HttpClient);
readonly #cookieService = inject(CookieService);
readonly #baseApiUrl = '/api';
#error = signal<HttpErrorResponse | undefined>(undefined);
serverError = computed(() => {
const error = this.#error();

return error?.status && error.status >= 500;
});

get authenticated() {
return this.#storeService.authenticated;
Expand Down Expand Up @@ -50,15 +56,31 @@ export class AuthService {
return this.credentials.refreshToken;
}

get lastError() {
return this.#error()?.status;
}

@loggedMethod()
isConnected() {
return publicGet<boolean>(this.#http, `${this.#baseApiUrl}/auth/connected`);
}

@loggedMethod()
login(email: string, password: string) {
const url = `${this.#baseApiUrl}/auth/login`;
const credentials = { email, password };

this.#error.set(undefined);

return publicPost(this.#http, url, credentials)
.pipe(
catchError((error) => {
console.error('http error:', error);
this.#error.set(error);
if (error instanceof HttpErrorResponse) {
console.error('http error:', error);
} else {
console.error('error:', error);
}
this.clearCredentials();

return of(null);
Expand Down
4 changes: 2 additions & 2 deletions apps/ui/src/app/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export const PUBLIC_API = new HttpContext().set(IS_PUBLIC_API, true);
export const IS_REFRESH_API = new HttpContextToken(() => false);
export const REFRESH_API = new HttpContext().set(IS_REFRESH_API, true);

export function publicGet(http: HttpClient, url: string) {
return http.get(url, { context: PUBLIC_API});
export function publicGet<T>(http: HttpClient, url: string) {
return http.get<T>(url, { context: PUBLIC_API});
}

export function publicPost<T>(http: HttpClient, url: string, body: T) {
Expand Down
18 changes: 17 additions & 1 deletion libs/prisma/src/lib/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,23 @@ import { PrismaClient } from '../../generated/prisma/client/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
protected connected = false;

get isConnected() {
return this.connected;
}

async onModuleInit() {
await this.$connect();
await this.connect();
}

async connect() {
try {
await this.$connect();
this.connected = true;
console.log('PrismaService initialized and connected to the database');
} catch (error) {
console.error('PrismaService database connection error:', error);
}
}
}
Loading
Loading