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
66 changes: 66 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: CI - Build Containers

on:
push:
branches: ['main', 'dev']
pull_request:
branches: ['main', 'dev']

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
validate-compose:
name: Validate docker-compose configuration
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Ensure .env for docker-compose
run: |
if [ -f .env ]; then
echo ".env already exists"
elif [ -f .env.example ]; then
cp .env.example .env
else
touch .env
fi

- name: Validate docker-compose.yaml
run: docker compose -f docker-compose.yaml config -q

build-images:
name: Build Docker images
needs: validate-compose
runs-on: ubuntu-latest
strategy:
# Realistically, in production, `fail-fast` should be true,
# but we want to see every issue during these early stages of implementing
fail-fast: false
matrix:
include:
- name: server
context: server
dockerfile: server/Dockerfile
- name: client
context: client
dockerfile: client/Dockerfile
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build ${{ matrix.name }} image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
push: false
tags: ghcr.io/${{ github.repository }}:${{ matrix.name }}-ci
cache-from: type=gha
cache-to: type=gha,mode=max
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@

*.env

# System files
.DS_Store
Thumbs.db
14 changes: 14 additions & 0 deletions client/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ FROM node:22.18-alpine3.22

WORKDIR /app

# Install Chrome for testing
RUN apk add --no-cache \
chromium \
nss \
freetype \
freetype-dev \
harfbuzz \
ca-certificates \
ttf-freefont

# Set Chrome binary path
ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/bin/chromium-browser

RUN npm install -g pnpm

COPY package.json pnpm-lock.yaml ./
Expand Down
47 changes: 47 additions & 0 deletions client/karma.conf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html

module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution order
// random: false
},
clearContext: false, // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true, // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/source-client'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
browsers: ['ChromeHeadless'],
customLaunchers: {
ChromeHeadless: {
base: 'Chrome',
flags: [
'--no-sandbox',
'--disable-web-security',
'--disable-gpu',
'--remote-debugging-port=9222',
],
},
},
restartOnFileChange: true,
})
}
4 changes: 2 additions & 2 deletions client/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { provideClientHydration, withEventReplay } from '@angular/platform-brows
import { provideRouter } from '@angular/router'
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'
import { provideMarkdown } from 'ngx-markdown'
import { MessageService as PrimeNGMessageService } from 'primeng/api'
import { ConfirmationService, MessageService as PrimeNGMessageService } from 'primeng/api'
import { providePrimeNG } from 'primeng/config'
import { baseInterceptor } from './shared/interceptors/base.interceptor'
import { routes } from './app.routes'
Expand All @@ -36,6 +36,6 @@ export const appConfig: ApplicationConfig = {
},
},
}),
makeEnvironmentProviders([PrimeNGMessageService]),
makeEnvironmentProviders([PrimeNGMessageService, ConfirmationService]),
],
}
39 changes: 39 additions & 0 deletions client/src/app/core/repositories/auth.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { HttpClient, HttpResponse } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface'
import { SessionUser } from '~/shared/interfaces/session-user.interface'

type MeResponse = LaravelApiResponse<SessionUser>
type LoginResponse = LaravelApiResponse<{
token: string
user: SessionUser
}>
type RegisterResponse = LoginResponse

@Injectable({ providedIn: 'root' })
export class AuthRepository {
protected http = inject(HttpClient)

me(): Observable<MeResponse> {
return this.http.get<MeResponse>('/api/me')
}

register(formValue: {
name: string
email: string
password: string
password_confirmation: string
avatar_url?: string
}): Observable<HttpResponse<RegisterResponse>> {
return this.http.post<RegisterResponse>('/api/register', formValue, { observe: 'response' })
}

login(formValue: { email: string; password: string }): Observable<HttpResponse<LoginResponse>> {
return this.http.post<LoginResponse>('/api/login', formValue, { observe: 'response' })
}

logout(): Observable<HttpResponse<unknown>> {
return this.http.post('/api/logout', null, { observe: 'response' })
}
}
12 changes: 6 additions & 6 deletions client/src/app/core/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ export class UserRepository {
protected http = inject(HttpClient)

storeFigmaToken(token: string): Observable<StoreFigmaTokenResponse> {
const formData = new FormData()
formData.append('figma_access_token', token)
return this.http.post<StoreFigmaTokenResponse>('/api/profile/figma-token', formData)
return this.http.post<StoreFigmaTokenResponse>('/api/profile/figma-token', {
figma_access_token: token,
})
}

storeBrevoToken(token: string): Observable<StoreBrevoTokenResponse> {
const formData = new FormData()
formData.append('brevo_api_token', token)
return this.http.post<StoreBrevoTokenResponse>('/api/profile/brevo-token', formData)
return this.http.post<StoreBrevoTokenResponse>('/api/profile/brevo-token', {
brevo_api_token: token,
})
}

removeFigmaToken(): Observable<DeleteTokenResponse> {
Expand Down
60 changes: 4 additions & 56 deletions client/src/app/core/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http'
import { inject, Injectable, signal } from '@angular/core'
import { Observable, of } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { LaravelApiResponse } from '~/shared/interfaces/laravel-api-response.interface'
import { SessionUser } from '~/shared/interfaces/session-user.interface'

type MeResponse = LaravelApiResponse<SessionUser>
type LoginResponse = LaravelApiResponse<{
token: string
user: SessionUser
}>
type RegisterResponse = LoginResponse
import { AuthRepository } from '~/core/repositories/auth.repository'

@Injectable({ providedIn: 'root' })
export class AuthService {
protected http = inject(HttpClient)
protected authRepository = inject(AuthRepository)

public user = signal<SessionUser | null>(null)
public isAuthenticated = signal<boolean>(false)

checkIfAuthenticated(): Observable<boolean> {
return this.http.get<MeResponse>('/api/me').pipe(
return this.authRepository.me().pipe(
map(res => {
this.user.set(res.payload)
this.isAuthenticated.set(true)
Expand All @@ -30,56 +22,12 @@ export class AuthService {
)
}

register(formData: FormData): Observable<{ success: boolean; errorMessage: string | null }> {
return this.http
.post<RegisterResponse>('/api/register', formData, { observe: 'response' })
.pipe(
map(res => {
if (res.ok && res.body?.payload?.user) {
this.user.set(res.body.payload.user)
this.isAuthenticated.set(true)
return { success: true, errorMessage: null }
}
return { success: false, errorMessage: res.body?.message || 'Login failed.' }
}),
catchError((errResp: HttpErrorResponse) => {
const errorMessage =
(errResp.error?.message as string) ||
(typeof errResp.error === 'string' ? errResp.error : null) ||
'Login failed. Please try again.'

return of({ success: false, errorMessage })
}),
)
}

login(formData: FormData): Observable<{ success: boolean; errorMessage: string | null }> {
return this.http.post<LoginResponse>('/api/login', formData, { observe: 'response' }).pipe(
map(res => {
if (res.ok && res.body?.payload?.user) {
this.user.set(res.body.payload.user)
this.isAuthenticated.set(true)
return { success: true, errorMessage: null }
}
return { success: false, errorMessage: res.body?.message || 'Login failed.' }
}),
catchError((errResp: HttpErrorResponse) => {
const errorMessage =
(errResp.error?.message as string) ||
(typeof errResp.error === 'string' ? errResp.error : null) ||
'Login failed. Please try again.'

return of({ success: false, errorMessage })
}),
)
}

getUser() {
return this.user.asReadonly()
}

logout(): Observable<boolean> {
return this.http.post('/api/logout', null, { observe: 'response' }).pipe(
return this.authRepository.logout().pipe(
map(res => {
if (res.ok) {
this.user.set(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
} from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import { HttpErrorResponse } from '@angular/common/http'
import { catchError } from 'rxjs/operators'
import { of } from 'rxjs'
import { Message } from 'primeng/message'
import { Button } from 'primeng/button'
import { InputText } from 'primeng/inputtext'
import { AuthService } from '~/core/services/auth.service'
import { AuthRepository } from '~/core/repositories/auth.repository'

@Component({
selector: 'app-register-form',
Expand All @@ -22,6 +25,7 @@ import { AuthService } from '~/core/services/auth.service'
export class RegisterForm {
protected fb = inject(NonNullableFormBuilder)
protected authService = inject(AuthService)
protected authRepository = inject(AuthRepository)
protected router = inject(Router)

protected isLoading = signal(false)
Expand Down Expand Up @@ -58,32 +62,38 @@ export class RegisterForm {
this.isLoading.set(true)
this.errorMessage.set(null)

const formData = new FormData()
formData.append('name', this.registerFb.get('name')?.value || '')
formData.append('email', this.registerFb.get('email')?.value || '')
formData.append('password', this.registerFb.get('password')?.value || '')
formData.append(
'password_confirmation',
this.registerFb.get('password_confirmation')?.value || '',
)
const formValue = this.registerFb.getRawValue()

this.authService.register(formData).subscribe({
next: ({ success, errorMessage }) => {
this.authRepository
.register(formValue)
.pipe(
catchError((err: HttpErrorResponse) => {
const errMsg =
(err.error?.message as string) ||
(typeof err.error === 'string' ? err.error : null) ||
'Registration failed. Please try again.'

this.errorMessage.set(errMsg)
this.isLoading.set(false)
return of(null)
}),
)
.subscribe(async resp => {
this.isLoading.set(false)
if (success) {
this.router.navigate(['/dashboard'])
} else {
this.errorMessage.set(errorMessage || 'Registration failed. Please try again.')

if (!resp) {
return
}
},
error: (err: HttpErrorResponse) => {
this.isLoading.set(false)
const errMsg =
(err.error?.message as string) ||
(typeof err.error === 'string' ? err.error : null) ||
'Registration failed. Please try again.'
this.errorMessage.set(errMsg)
},
})

if (!(resp.ok && resp.body?.payload?.user)) {
const message = resp.body?.message || 'Registration failed. Please try again.'
this.errorMessage.set(message)
return
}

this.authService.user.set(resp.body.payload.user)
this.authService.isAuthenticated.set(true)
await this.router.navigate(['/dashboard'])
})
}
}
Loading