├── Procfile ├── .husky └── pre-commit ├── .dockerignore ├── client ├── app │ ├── add-cat-form │ │ ├── add-cat-form.component.scss │ │ ├── add-cat-form.component.html │ │ ├── add-cat-form.component.ts │ │ └── add-cat-form.component.spec.ts │ ├── cats │ │ ├── cats.component.scss │ │ ├── cats.component.ts │ │ ├── cats.component.html │ │ └── cats.component.spec.ts │ ├── about │ │ ├── about.component.scss │ │ ├── about.component.ts │ │ ├── about.component.spec.ts │ │ └── about.component.html │ ├── shared │ │ ├── models │ │ │ ├── cat.model.ts │ │ │ └── user.model.ts │ │ ├── toast │ │ │ ├── toast.component.scss │ │ │ ├── toast.component.html │ │ │ ├── toast.component.ts │ │ │ └── toast.component.spec.ts │ │ ├── loading │ │ │ ├── loading.component.html │ │ │ ├── loading.component.ts │ │ │ └── loading.component.spec.ts │ │ └── shared.module.ts │ ├── not-found │ │ ├── not-found.component.html │ │ ├── not-found.component.ts │ │ └── not-found.component.spec.ts │ ├── services │ │ ├── auth-guard-admin.service.ts │ │ ├── auth-guard-login.service.ts │ │ ├── cat.service.ts │ │ ├── user.service.ts │ │ └── auth.service.ts │ ├── logout │ │ ├── logout.component.ts │ │ └── logout.component.spec.ts │ ├── app.component.ts │ ├── login │ │ ├── login.component.html │ │ ├── login.component.ts │ │ └── login.component.spec.ts │ ├── account │ │ ├── account.component.ts │ │ ├── account.component.html │ │ └── account.component.spec.ts │ ├── admin │ │ ├── admin.component.ts │ │ ├── admin.component.html │ │ └── admin.component.spec.ts │ ├── app-routing.module.ts │ ├── register │ │ ├── register.component.html │ │ ├── register.component.ts │ │ └── register.component.spec.ts │ ├── app.module.ts │ ├── app.component.html │ └── app.component.spec.ts ├── assets │ └── favicon.ico ├── main.ts ├── styles.scss └── index.html ├── demo.gif ├── proxy.conf.json ├── .env ├── jest.config.js ├── server ├── controllers │ ├── cat.ts │ ├── user.ts │ └── base.ts ├── tsconfig.json ├── models │ ├── cat.ts │ └── user.ts ├── mongo.ts ├── app.ts ├── routes.ts └── test │ ├── cats.spec.ts │ └── users.spec.ts ├── Dockerfile ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── tests.yml ├── docker-compose.yml ├── .editorconfig ├── tsconfig.app.json ├── tsconfig.spec.json ├── .gitignore ├── .htmlhintrc ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── package.json ├── README.md └── angular.json /Procfile: -------------------------------------------------------------------------------- 1 | web: node dist/server/app.js 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | npm run build -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules/ 3 | dist/ 4 | -------------------------------------------------------------------------------- /client/app/add-cat-form/add-cat-form.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavideViolante/Angular-Full-Stack/HEAD/demo.gif -------------------------------------------------------------------------------- /client/app/cats/cats.component.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | td, 3 | th { 4 | width: 25%; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3000", 4 | "secure": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DavideViolante/Angular-Full-Stack/HEAD/client/assets/favicon.ico -------------------------------------------------------------------------------- /client/app/about/about.component.scss: -------------------------------------------------------------------------------- 1 | ul { 2 | &:first-child { 3 | list-style-type: none; 4 | margin-left: -20px; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | MONGODB_URI=mongodb://localhost:27017/angularfullstack 2 | MONGODB_TEST_URI=mongodb://localhost:27017/test 3 | SECRET_TOKEN=catswillruletheworld -------------------------------------------------------------------------------- /client/app/shared/models/cat.model.ts: -------------------------------------------------------------------------------- 1 | export class Cat { 2 | _id?: string; 3 | name?: string; 4 | weight?: number; 5 | age?: number; 6 | } 7 | -------------------------------------------------------------------------------- /client/app/shared/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | _id?: string; 3 | username?: string; 4 | email?: string; 5 | role?: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/app/shared/toast/toast.component.scss: -------------------------------------------------------------------------------- 1 | .alert { 2 | bottom: 0; 3 | left: 25%; 4 | opacity: .9; 5 | position: fixed; 6 | width: 50%; 7 | z-index: 999; 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testRegex: '(/server/test/.*spec.ts)$' 6 | }; -------------------------------------------------------------------------------- /server/controllers/cat.ts: -------------------------------------------------------------------------------- 1 | import Cat, { ICat } from '../models/cat'; 2 | import BaseCtrl from './base'; 3 | 4 | class CatCtrl extends BaseCtrl { 5 | model = Cat; 6 | } 7 | 8 | export default CatCtrl; 9 | -------------------------------------------------------------------------------- /client/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | 3 | import { AppModule } from './app/app.module'; 4 | 5 | 6 | platformBrowserDynamic().bootstrapModule(AppModule) 7 | .catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /client/app/not-found/not-found.component.html: -------------------------------------------------------------------------------- 1 |
2 |

404 Not Found

3 |
4 |

The page you requested was not found.

5 |

Go to Homepage.

6 |
7 |
-------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/server", 5 | "baseUrl": "", 6 | "moduleResolution": "node", 7 | "module": "commonjs", 8 | "types": ["node", "jest"], 9 | } 10 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /usr/src/app 4 | COPY package*.json ./ 5 | RUN npm ci 6 | COPY . . 7 | ENV MONGODB_URI mongodb://mongo:27017/angularfullstack 8 | #RUN npm run build:dev 9 | RUN npm run build 10 | EXPOSE 3000 11 | CMD [ "npm", "start" ] 12 | -------------------------------------------------------------------------------- /client/app/shared/loading/loading.component.html: -------------------------------------------------------------------------------- 1 | @if (condition) { 2 |
3 |

Loading...

4 |
5 | 6 |
7 |
8 | } -------------------------------------------------------------------------------- /client/app/not-found/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-not-found', 5 | templateUrl: './not-found.component.html', 6 | standalone: false 7 | }) 8 | export class NotFoundComponent { 9 | 10 | } 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [DavideViolante] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | custom: ['https://www.paypal.me/dviolante'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 5 | -------------------------------------------------------------------------------- /client/app/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-about', 5 | templateUrl: './about.component.html', 6 | styleUrls: ['./about.component.scss'], 7 | standalone: false 8 | }) 9 | export class AboutComponent { 10 | 11 | } 12 | -------------------------------------------------------------------------------- /client/app/shared/loading/loading.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-loading', 5 | templateUrl: './loading.component.html', 6 | standalone: false 7 | }) 8 | export class LoadingComponent { 9 | @Input() condition = false; 10 | } 11 | -------------------------------------------------------------------------------- /client/app/services/auth-guard-admin.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | 3 | import { AuthService } from './auth.service'; 4 | 5 | @Injectable() 6 | export class AuthGuardAdmin { 7 | auth = inject(AuthService); 8 | 9 | 10 | canActivate(): boolean { 11 | return this.auth.isAdmin; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /client/app/services/auth-guard-login.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | 3 | import { AuthService } from './auth.service'; 4 | 5 | @Injectable() 6 | export class AuthGuardLogin { 7 | auth = inject(AuthService); 8 | 9 | 10 | canActivate(): boolean { 11 | return this.auth.loggedIn; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | app: 4 | container_name: afs 5 | restart: always 6 | build: . 7 | ports: 8 | - "3000:3000" 9 | links: 10 | - mongo 11 | mongo: 12 | container_name: mongo 13 | image: mongo 14 | volumes: 15 | - ./data:/data/db 16 | ports: 17 | - "27017:27017" -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /client/app/shared/toast/toast.component.html: -------------------------------------------------------------------------------- 1 | @if (message.body) { 2 | 9 | } -------------------------------------------------------------------------------- /server/models/cat.ts: -------------------------------------------------------------------------------- 1 | import { model, Schema } from 'mongoose'; 2 | 3 | interface ICat { 4 | name: string; 5 | weight: number; 6 | age: number; 7 | } 8 | 9 | const catSchema = new Schema({ 10 | name: String, 11 | weight: Number, 12 | age: Number 13 | }); 14 | 15 | const Cat = model('Cat', catSchema); 16 | 17 | export type { ICat }; 18 | export default Cat; 19 | -------------------------------------------------------------------------------- /client/styles.scss: -------------------------------------------------------------------------------- 1 | // You can add global styles to this file, and also import other style files 2 | 3 | // Add Bootstrap style overrides here 4 | $primary: #026fbd; 5 | 6 | @import '../node_modules/bootstrap/scss/bootstrap'; 7 | 8 | // Add global app styles here 9 | body { 10 | margin-bottom: 10px; 11 | margin-top: 15px; 12 | } 13 | 14 | .input-group { 15 | margin-bottom: 15px; 16 | } 17 | -------------------------------------------------------------------------------- /client/app/logout/logout.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { AuthService } from '../services/auth.service'; 3 | 4 | @Component({ 5 | selector: 'app-logout', 6 | template: '', 7 | standalone: false 8 | }) 9 | export class LogoutComponent implements OnInit { 10 | private auth = inject(AuthService); 11 | 12 | 13 | ngOnInit(): void { 14 | this.auth.logout(); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '20' 17 | cache: 'npm' 18 | - run: npm ci 19 | - run: npm run lint 20 | - run: npm run build -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Full Stack 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": ["node"] 8 | }, 9 | "files": [ 10 | "client/main.ts" 11 | ], 12 | "include": [ 13 | "client/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "client/**/*.spec.ts", 13 | "client/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /server/mongo.ts: -------------------------------------------------------------------------------- 1 | import { connect, connection } from 'mongoose'; 2 | 3 | const connectToMongo = async (): Promise => { 4 | let mongodbURI: string; 5 | if (process.env.NODE_ENV === 'test') { 6 | mongodbURI = process.env.MONGODB_TEST_URI as string; 7 | } else { 8 | mongodbURI = process.env.MONGODB_URI as string; 9 | } 10 | await connect(mongodbURI); 11 | console.log(`Connected to MongoDB (db: ${mongodbURI.split('/').pop()})`); 12 | }; 13 | 14 | const disconnectMongo = async (): Promise => { 15 | await connection.close(); 16 | }; 17 | 18 | export { connectToMongo, disconnectMongo }; 19 | -------------------------------------------------------------------------------- /client/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { AfterViewChecked, ChangeDetectorRef, Component, inject } from '@angular/core'; 2 | import { AuthService } from './services/auth.service'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | standalone: false 8 | }) 9 | export class AppComponent implements AfterViewChecked { 10 | auth = inject(AuthService); 11 | private changeDetector = inject(ChangeDetectorRef); 12 | 13 | 14 | // This fixes: https://github.com/DavideViolante/Angular-Full-Stack/issues/105 15 | ngAfterViewChecked(): void { 16 | this.changeDetector.detectChanges(); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | services: 13 | mongodb: 14 | image: mongo:5.0.13 15 | ports: 16 | - 27017:27017 17 | if: "!contains(github.event.head_commit.message, '[skip tests]')" 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run test -- --watch=false --progress=false --browsers=ChromeHeadless 26 | - run: npm run test:be -------------------------------------------------------------------------------- /client/app/shared/toast/toast.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-toast', 5 | templateUrl: './toast.component.html', 6 | styleUrls: ['./toast.component.scss'], 7 | standalone: false 8 | }) 9 | export class ToastComponent { 10 | @Input() message = { body: '', type: '' }; 11 | existingTimeout = 0; 12 | 13 | setMessage(body: string, type: string, time = 3000): void { 14 | if (this.existingTimeout) { 15 | clearTimeout(this.existingTimeout); 16 | } 17 | this.message.body = body; 18 | this.message.type = type; 19 | this.existingTimeout = window.setTimeout(() => this.message.body = '', time); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # MongoDB 45 | /data 46 | -------------------------------------------------------------------------------- /.htmlhintrc: -------------------------------------------------------------------------------- 1 | // https://github.com/htmlhint/HTMLHint/wiki/Rules 2 | { 3 | "tagname-lowercase": true, 4 | "attr-lowercase": false, 5 | "attr-value-double-quotes": true, 6 | "attr-value-not-empty": false, 7 | "attr-no-duplication": true, 8 | "doctype-first": false, 9 | "tag-pair": true, 10 | "tag-self-close": false, 11 | "spec-char-escape": true, 12 | "id-unique": true, 13 | "src-not-empty": true, 14 | "title-require": true, 15 | "alt-require": true, 16 | "doctype-html5": true, 17 | "id-class-value": "dash", 18 | "style-disabled": true, 19 | "inline-style-disabled": true, 20 | "inline-script-disabled": false, 21 | "space-tab-mixed-disabled": "space", 22 | "id-class-ad-disabled": false, 23 | "href-abs-or-rel": false, 24 | "attr-unsafe-chars": true, 25 | "head-script-disabled": true 26 | } -------------------------------------------------------------------------------- /client/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; 5 | 6 | import { ToastComponent } from './toast/toast.component'; 7 | import { LoadingComponent } from './loading/loading.component'; 8 | 9 | @NgModule({ 10 | exports: [ 11 | // Shared Modules 12 | BrowserModule, 13 | FormsModule, 14 | ReactiveFormsModule, 15 | // Shared Components 16 | ToastComponent, 17 | LoadingComponent, 18 | ], 19 | declarations: [ 20 | ToastComponent, 21 | LoadingComponent 22 | ], 23 | imports: [ 24 | BrowserModule, 25 | FormsModule, 26 | ReactiveFormsModule 27 | ], 28 | providers: [ 29 | ToastComponent, 30 | provideHttpClient(withInterceptorsFromDi()) 31 | ], 32 | }) 33 | export class SharedModule {} 34 | -------------------------------------------------------------------------------- /server/controllers/user.ts: -------------------------------------------------------------------------------- 1 | import { sign, Secret } from 'jsonwebtoken'; 2 | import { Request, Response } from 'express'; 3 | 4 | import User, { IUser } from '../models/user'; 5 | import BaseCtrl from './base'; 6 | 7 | const secret: Secret = process.env.SECRET_TOKEN as string; 8 | 9 | class UserCtrl extends BaseCtrl { 10 | model = User; 11 | 12 | login = async (req: Request, res: Response) => { 13 | try { 14 | const user = await this.model.findOne({ email: req.body.email }); 15 | if (!user) { 16 | return res.sendStatus(403); 17 | } 18 | return user.comparePassword(req.body.password, (error, isMatch: boolean) => { 19 | if (error || !isMatch) { 20 | return res.sendStatus(403); 21 | } 22 | const token = sign({ user }, secret, { expiresIn: '24h' }); 23 | return res.status(200).json({ token }); 24 | }); 25 | } catch (err) { 26 | return res.status(400).json({ error: (err as Error).message }); 27 | } 28 | }; 29 | 30 | } 31 | 32 | export default UserCtrl; 33 | -------------------------------------------------------------------------------- /client/app/about/about.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AboutComponent } from './about.component'; 4 | 5 | describe('Component: About', () => { 6 | let component: AboutComponent; 7 | let fixture: ComponentFixture; 8 | let compiled: HTMLElement; 9 | 10 | beforeEach(waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ AboutComponent ] 13 | }) 14 | .compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(AboutComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | compiled = fixture.nativeElement as HTMLElement; 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should display the page header text', () => { 29 | const header = compiled.querySelector('.card-header'); 30 | expect(header?.textContent).toContain('About'); 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /server/app.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import morgan from 'morgan'; 4 | import { join as pathJoin } from 'path'; 5 | 6 | import { connectToMongo } from './mongo'; 7 | import setRoutes from './routes'; 8 | 9 | const app = express(); 10 | app.set('port', (process.env.PORT || 3000)); 11 | app.use('/', express.static(pathJoin(__dirname, '../public'))); 12 | app.use(express.json()); 13 | app.use(express.urlencoded({ extended: false })); 14 | if (process.env.NODE_ENV !== 'test') { 15 | app.use(morgan('dev')); 16 | } 17 | 18 | setRoutes(app); 19 | 20 | const main = async (): Promise => { 21 | try { 22 | await connectToMongo(); 23 | app.get('/*', (req, res) => { 24 | res.sendFile(pathJoin(__dirname, '../public/index.html')); 25 | }); 26 | app.listen(app.get('port'), () => console.log(`Angular Full Stack listening on port ${app.get('port')}`)); 27 | } catch (err) { 28 | console.error(err); 29 | } 30 | }; 31 | 32 | if (process.env.NODE_ENV !== 'test') { 33 | main(); 34 | } 35 | 36 | export { app }; 37 | -------------------------------------------------------------------------------- /client/app/services/cat.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Cat } from '../shared/models/cat.model'; 6 | 7 | @Injectable() 8 | export class CatService { 9 | private http = inject(HttpClient); 10 | 11 | 12 | getCats(): Observable { 13 | return this.http.get('/api/cats'); 14 | } 15 | 16 | countCats(): Observable { 17 | return this.http.get('/api/cats/count'); 18 | } 19 | 20 | addCat(cat: Cat): Observable { 21 | return this.http.post('/api/cat', cat); 22 | } 23 | 24 | getCat(cat: Cat): Observable { 25 | return this.http.get(`/api/cat/${cat._id}`); 26 | } 27 | 28 | editCat(cat: Cat): Observable { 29 | return this.http.put(`/api/cat/${cat._id}`, cat, { responseType: 'text' }); 30 | } 31 | 32 | deleteCat(cat: Cat): Observable { 33 | return this.http.delete(`/api/cat/${cat._id}`, { responseType: 'text' }); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /client/app/add-cat-form/add-cat-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Add new cat

3 |
4 |
5 |
6 |
7 | 9 |
10 |
11 | 13 |
14 |
15 | 17 |
18 |
19 | 22 |
23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /client/app/login/login.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Login

5 |
6 |
7 |
8 | 9 | 10 | 11 | 13 |
14 |
15 | 16 | 17 | 18 | 20 |
21 | 24 |
25 |
26 |
-------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "baseUrl": "./", 7 | "outDir": "./dist/out-tsc", 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": false, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "bundler", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "lib": [ 23 | "ES2022", 24 | "dom" 25 | ] 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Davide Violante 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/app/not-found/not-found.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NotFoundComponent } from './not-found.component'; 4 | 5 | describe('Component: NotFound', () => { 6 | let component: NotFoundComponent; 7 | let fixture: ComponentFixture; 8 | let compiled: HTMLElement; 9 | 10 | beforeEach(waitForAsync(() => { 11 | TestBed.configureTestingModule({ 12 | declarations: [ NotFoundComponent ] 13 | }) 14 | .compileComponents(); 15 | })); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(NotFoundComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | compiled = fixture.nativeElement as HTMLElement; 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('should display the page header text', () => { 29 | const header = compiled.querySelector('.card-header'); 30 | expect(header?.textContent).toContain('404 Not Found'); 31 | }); 32 | 33 | it('should display the link for homepage', () => { 34 | const link = compiled.querySelector('a'); 35 | expect(link?.getAttribute('routerLink')).toBe('/'); 36 | expect(link?.textContent).toContain('Homepage'); 37 | }); 38 | 39 | }); 40 | -------------------------------------------------------------------------------- /client/app/logout/logout.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AuthService } from '../services/auth.service'; 4 | import { LogoutComponent } from './logout.component'; 5 | 6 | class AuthServiceMock { 7 | loggedIn = true; 8 | logout(): void { 9 | this.loggedIn = false; 10 | } 11 | } 12 | 13 | describe('Component: Logout', () => { 14 | let component: LogoutComponent; 15 | let fixture: ComponentFixture; 16 | let authService: AuthService; 17 | 18 | beforeEach(waitForAsync(() => { 19 | TestBed.configureTestingModule({ 20 | declarations: [ LogoutComponent ], 21 | providers: [ { provide: AuthService, useClass: AuthServiceMock } ], 22 | }) 23 | .compileComponents(); 24 | })); 25 | 26 | beforeEach(() => { 27 | fixture = TestBed.createComponent(LogoutComponent); 28 | component = fixture.componentInstance; 29 | authService = fixture.debugElement.injector.get(AuthService); 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it('should logout the user', () => { 38 | authService.loggedIn = true; 39 | expect(authService.loggedIn).toBeTruthy(); 40 | authService.logout(); 41 | expect(authService.loggedIn).toBeFalsy(); 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /server/routes.ts: -------------------------------------------------------------------------------- 1 | import { Router, Application } from 'express'; 2 | 3 | import CatCtrl from './controllers/cat'; 4 | import UserCtrl from './controllers/user'; 5 | 6 | const setRoutes = (app: Application): void => { 7 | const router = Router(); 8 | const catCtrl = new CatCtrl(); 9 | const userCtrl = new UserCtrl(); 10 | 11 | // Cats 12 | router.route('/cats').get(catCtrl.getAll); 13 | router.route('/cats/count').get(catCtrl.count); 14 | router.route('/cat').post(catCtrl.insert); 15 | router.route('/cat/:id').get(catCtrl.get); 16 | router.route('/cat/:id').put(catCtrl.update); 17 | router.route('/cat/:id').delete(catCtrl.delete); 18 | 19 | // Users 20 | router.route('/login').post(userCtrl.login); 21 | router.route('/users').get(userCtrl.getAll); 22 | router.route('/users/count').get(userCtrl.count); 23 | router.route('/user').post(userCtrl.insert); 24 | router.route('/user/:id').get(userCtrl.get); 25 | router.route('/user/:id').put(userCtrl.update); 26 | router.route('/user/:id').delete(userCtrl.delete); 27 | 28 | // Test routes 29 | if (process.env.NODE_ENV === 'test') { 30 | router.route('/cats/delete').delete(catCtrl.deleteAll); 31 | router.route('/users/delete').delete(userCtrl.deleteAll); 32 | } 33 | 34 | // Apply the routes to our application with the prefix /api 35 | app.use('/api', router); 36 | 37 | }; 38 | 39 | export default setRoutes; 40 | -------------------------------------------------------------------------------- /client/app/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { User } from '../shared/models/user.model'; 6 | 7 | @Injectable() 8 | export class UserService { 9 | private http = inject(HttpClient); 10 | 11 | 12 | register(user: User): Observable { 13 | return this.http.post('/api/user', user); 14 | } 15 | 16 | login(credentials: { email: string; password: string }): Observable<{ token: string }> { 17 | return this.http.post<{ token: string }>('/api/login', credentials); 18 | } 19 | 20 | getUsers(): Observable { 21 | return this.http.get('/api/users'); 22 | } 23 | 24 | countUsers(): Observable { 25 | return this.http.get('/api/users/count'); 26 | } 27 | 28 | addUser(user: User): Observable { 29 | return this.http.post('/api/user', user); 30 | } 31 | 32 | getUser(user: User): Observable { 33 | return this.http.get(`/api/user/${user._id}`); 34 | } 35 | 36 | editUser(user: User): Observable { 37 | return this.http.put(`/api/user/${user._id}`, user, { responseType: 'text' }); 38 | } 39 | 40 | deleteUser(user: User): Observable { 41 | return this.http.delete(`/api/user/${user._id}`, { responseType: 'text' }); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /client/app/account/account.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { ToastComponent } from '../shared/toast/toast.component'; 3 | import { AuthService } from '../services/auth.service'; 4 | import { UserService } from '../services/user.service'; 5 | import { User } from '../shared/models/user.model'; 6 | 7 | @Component({ 8 | selector: 'app-account', 9 | templateUrl: './account.component.html', 10 | standalone: false 11 | }) 12 | export class AccountComponent implements OnInit { 13 | private auth = inject(AuthService); 14 | toast = inject(ToastComponent); 15 | private userService = inject(UserService); 16 | 17 | 18 | user: User = new User(); 19 | isLoading = true; 20 | 21 | ngOnInit(): void { 22 | this.getUser(); 23 | } 24 | 25 | getUser(): void { 26 | this.userService.getUser(this.auth.currentUser).subscribe({ 27 | next: data => this.user = data, 28 | error: error => console.log(error), 29 | complete: () => this.isLoading = false 30 | }); 31 | } 32 | 33 | save(user: User): void { 34 | this.userService.editUser(user).subscribe({ 35 | next: () => { 36 | this.toast.setMessage('Account settings saved!', 'success'); 37 | this.auth.currentUser = user; 38 | this.auth.isAdmin = user.role === 'admin'; 39 | }, 40 | error: error => console.log(error) 41 | }); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /client/app/admin/admin.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | 3 | import { ToastComponent } from '../shared/toast/toast.component'; 4 | import { AuthService } from '../services/auth.service'; 5 | import { UserService } from '../services/user.service'; 6 | import { User } from '../shared/models/user.model'; 7 | 8 | @Component({ 9 | selector: 'app-admin', 10 | templateUrl: './admin.component.html', 11 | standalone: false 12 | }) 13 | export class AdminComponent implements OnInit { 14 | auth = inject(AuthService); 15 | toast = inject(ToastComponent); 16 | private userService = inject(UserService); 17 | 18 | 19 | users: User[] = []; 20 | isLoading = true; 21 | 22 | ngOnInit(): void { 23 | this.getUsers(); 24 | } 25 | 26 | getUsers(): void { 27 | this.userService.getUsers().subscribe({ 28 | next: data => this.users = data, 29 | error: error => console.log(error), 30 | complete: () => this.isLoading = false 31 | }); 32 | } 33 | 34 | deleteUser(user: User): void { 35 | if (window.confirm('Are you sure you want to delete ' + user.username + '?')) { 36 | this.userService.deleteUser(user).subscribe({ 37 | next: () => this.toast.setMessage('User deleted successfully.', 'success'), 38 | error: error => console.log(error), 39 | complete: () => this.getUsers() 40 | }); 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /client/app/shared/loading/loading.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { LoadingComponent } from './loading.component'; 5 | 6 | describe('Component: Loading', () => { 7 | let component: LoadingComponent; 8 | let fixture: ComponentFixture; 9 | let compiled: HTMLElement; 10 | 11 | beforeEach(waitForAsync(() => { 12 | TestBed.configureTestingModule({ 13 | declarations: [ LoadingComponent ], 14 | schemas: [NO_ERRORS_SCHEMA] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(LoadingComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | compiled = fixture.nativeElement as HTMLElement; 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should not show the DOM element', () => { 31 | const div = compiled.querySelector('div'); 32 | expect(div).toBeNull(); 33 | }); 34 | 35 | it('should show the DOM element', () => { 36 | component.condition = true; 37 | fixture.detectChanges(); 38 | expect(component).toBeTruthy(); 39 | const div = compiled.querySelector('div'); 40 | expect(div).toBeDefined(); 41 | expect(div?.textContent).toContain('Loading...'); 42 | }); 43 | 44 | }); 45 | -------------------------------------------------------------------------------- /client/app/admin/admin.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @if (!isLoading) { 6 |
7 |

Registered users ({{users.length}})

8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | @if (users.length === 0) { 19 | 20 | 21 | 22 | 23 | 24 | } 25 | 26 | @for (user of users; track user) { 27 | 28 | 29 | 30 | 31 | 37 | 38 | } 39 | 40 |
UsernameEmailRoleActions
There are no registered users.
{{user.username}}{{user.email}}{{user.role}} 32 | 36 |
41 |
42 |
43 | } -------------------------------------------------------------------------------- /client/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | // Angular 2 | import { NgModule } from '@angular/core'; 3 | import { RouterModule, Routes } from '@angular/router'; 4 | // Services 5 | import { AuthGuardLogin } from './services/auth-guard-login.service'; 6 | import { AuthGuardAdmin } from './services/auth-guard-admin.service'; 7 | // Components 8 | import { CatsComponent } from './cats/cats.component'; 9 | import { AboutComponent } from './about/about.component'; 10 | import { RegisterComponent } from './register/register.component'; 11 | import { LoginComponent } from './login/login.component'; 12 | import { LogoutComponent } from './logout/logout.component'; 13 | import { AccountComponent } from './account/account.component'; 14 | import { AdminComponent } from './admin/admin.component'; 15 | import { NotFoundComponent } from './not-found/not-found.component'; 16 | 17 | const routes: Routes = [ 18 | { path: '', component: AboutComponent }, 19 | { path: 'cats', component: CatsComponent }, 20 | { path: 'register', component: RegisterComponent }, 21 | { path: 'login', component: LoginComponent }, 22 | { path: 'logout', component: LogoutComponent }, 23 | { path: 'account', component: AccountComponent, canActivate: [AuthGuardLogin] }, 24 | { path: 'admin', component: AdminComponent, canActivate: [AuthGuardAdmin] }, 25 | { path: 'notfound', component: NotFoundComponent }, 26 | { path: '**', redirectTo: '/notfound' }, 27 | ]; 28 | 29 | @NgModule({ 30 | imports: [RouterModule.forRoot(routes)], 31 | exports: [RouterModule] 32 | }) 33 | 34 | export class AppRoutingModule {} 35 | -------------------------------------------------------------------------------- /client/app/add-cat-form/add-cat-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, inject } from '@angular/core'; 2 | import { UntypedFormGroup, UntypedFormControl, Validators, UntypedFormBuilder } from '@angular/forms'; 3 | import { CatService } from '../services/cat.service'; 4 | import { ToastComponent } from '../shared/toast/toast.component'; 5 | import { Cat } from '../shared/models/cat.model'; 6 | 7 | @Component({ 8 | selector: 'app-add-cat-form', 9 | templateUrl: './add-cat-form.component.html', 10 | styleUrls: ['./add-cat-form.component.scss'], 11 | standalone: false 12 | }) 13 | 14 | export class AddCatFormComponent { 15 | private catService = inject(CatService); 16 | private formBuilder = inject(UntypedFormBuilder); 17 | toast = inject(ToastComponent); 18 | 19 | @Input() cats: Cat[] = []; 20 | 21 | addCatForm: UntypedFormGroup; 22 | name = new UntypedFormControl('', Validators.required); 23 | age = new UntypedFormControl('', Validators.required); 24 | weight = new UntypedFormControl('', Validators.required); 25 | 26 | constructor() { 27 | this.addCatForm = this.formBuilder.group({ 28 | name: this.name, 29 | age: this.age, 30 | weight: this.weight 31 | }); 32 | } 33 | 34 | addCat(): void { 35 | this.catService.addCat(this.addCatForm.value).subscribe({ 36 | next: res => { 37 | this.cats.push(res); 38 | this.addCatForm.reset(); 39 | this.toast.setMessage('Item added successfully.', 'success'); 40 | }, 41 | error: error => console.log(error) 42 | }); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /client/app/account/account.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @if (!isLoading) { 6 |
7 |

Account settings

8 |
9 |
10 |
11 | 12 | 13 | 14 | 16 |
17 |
18 | 19 | 20 | 21 | 23 |
24 |
25 | 26 | 27 | 28 | 33 |
34 | 37 |
38 |
39 |
40 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const eslint = require("@eslint/js"); 3 | const tseslint = require("typescript-eslint"); 4 | const angular = require("angular-eslint"); 5 | 6 | module.exports = tseslint.config( 7 | { 8 | files: ["**/*.ts"], 9 | extends: [ 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | ...tseslint.configs.stylistic, 13 | ...angular.configs.tsRecommended, 14 | ], 15 | processor: angular.processInlineTemplates, 16 | rules: { 17 | "@angular-eslint/directive-selector": [ 18 | "error", 19 | { 20 | type: "attribute", 21 | prefix: "app", 22 | style: "camelCase", 23 | }, 24 | ], 25 | "@angular-eslint/component-selector": [ 26 | "error", 27 | { 28 | type: "element", 29 | prefix: "app", 30 | style: "kebab-case", 31 | }, 32 | ], 33 | "@angular-eslint/prefer-standalone": [ 34 | "off" 35 | ], 36 | "arrow-spacing": "error", 37 | "comma-spacing": "error", 38 | "indent": ["error", 2], 39 | "key-spacing": "error", 40 | "keyword-spacing": "error", 41 | "object-curly-spacing": ["error", "always"], 42 | "object-shorthand": "error", 43 | "spaced-comment": "error", 44 | "space-before-blocks": "error", 45 | "space-infix-ops": "error", 46 | "semi": "error", 47 | }, 48 | }, 49 | { 50 | files: ["**/*.html"], 51 | extends: [ 52 | ...angular.configs.templateRecommended, 53 | ...angular.configs.templateAccessibility, 54 | ], 55 | rules: {}, 56 | } 57 | ); 58 | -------------------------------------------------------------------------------- /client/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; 4 | 5 | import { AuthService } from '../services/auth.service'; 6 | import { ToastComponent } from '../shared/toast/toast.component'; 7 | 8 | @Component({ 9 | selector: 'app-login', 10 | templateUrl: './login.component.html', 11 | standalone: false 12 | }) 13 | export class LoginComponent implements OnInit { 14 | private auth = inject(AuthService); 15 | private formBuilder = inject(UntypedFormBuilder); 16 | private router = inject(Router); 17 | toast = inject(ToastComponent); 18 | 19 | 20 | loginForm: UntypedFormGroup; 21 | email = new UntypedFormControl('', [ 22 | Validators.email, 23 | Validators.required, 24 | Validators.minLength(3), 25 | Validators.maxLength(100) 26 | ]); 27 | password = new UntypedFormControl('', [ 28 | Validators.required, 29 | Validators.minLength(6) 30 | ]); 31 | 32 | constructor() { 33 | this.loginForm = this.formBuilder.group({ 34 | email: this.email, 35 | password: this.password 36 | }); 37 | } 38 | 39 | ngOnInit(): void { 40 | if (this.auth.loggedIn) { 41 | this.router.navigate(['/']); 42 | } 43 | } 44 | 45 | setClassEmail(): object { 46 | return { 'has-danger': !this.email.pristine && !this.email.valid }; 47 | } 48 | 49 | setClassPassword(): object { 50 | return { 'has-danger': !this.password.pristine && !this.password.valid }; 51 | } 52 | 53 | login(): void { 54 | this.auth.login(this.loginForm.value); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /server/models/user.ts: -------------------------------------------------------------------------------- 1 | import { compare, genSalt, hash } from 'bcryptjs'; 2 | import { model, Schema } from 'mongoose'; 3 | 4 | interface IUser { 5 | username: string; 6 | email: string; 7 | password: string; 8 | role: string; 9 | isModified(password: string): boolean; 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | comparePassword(password: string, callback: (err: any, isMatch: boolean) => void): boolean; 12 | } 13 | 14 | const userSchema = new Schema({ 15 | email: { type: String, unique: true, lowercase: true, trim: true }, 16 | username: String, 17 | password: String, 18 | role: String 19 | }); 20 | 21 | // Before saving the user, hash the password 22 | userSchema.pre('save', function(next): void { 23 | // eslint-disable-next-line @typescript-eslint/no-this-alias 24 | const user = this; 25 | if (!user.isModified('password')) { return next(); } 26 | genSalt(10, (err, salt) => { 27 | if (err) { return next(err); } 28 | hash(user.password, salt, (error, hashedPassword) => { 29 | if (error) { return next(error); } 30 | user.password = hashedPassword; 31 | next(); 32 | }); 33 | }); 34 | }); 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | userSchema.methods.comparePassword = function(candidatePassword: string, callback: any): void { 38 | compare(candidatePassword, this.password, (err, isMatch) => { 39 | if (err) { return callback(err); } 40 | callback(null, isMatch); 41 | }); 42 | }; 43 | 44 | // Omit the password when returning a user 45 | userSchema.set('toJSON', { 46 | transform: (doc, ret) => { 47 | delete ret.password; 48 | return ret; 49 | } 50 | }); 51 | 52 | const User = model('User', userSchema); 53 | 54 | export type { IUser }; 55 | export default User; 56 | -------------------------------------------------------------------------------- /server/test/cats.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import request from 'supertest'; 3 | import { describe, expect, test } from '@jest/globals'; 4 | process.env.NODE_ENV = 'test'; 5 | 6 | import { app } from '../app'; 7 | import { connectToMongo, disconnectMongo } from '../mongo'; 8 | 9 | const newCat = { name: 'Fluffy', weight: 4, age: 2 }; 10 | let catId = ''; 11 | 12 | beforeAll(async () => { 13 | await connectToMongo(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await request(app).delete('/api/cats/delete'); 18 | await disconnectMongo(); 19 | }); 20 | 21 | describe('Cat tests', () => { 22 | test('should get all cats', async () => { 23 | const res = await request(app).get('/api/cats'); 24 | expect(res.statusCode).toBe(200); 25 | expect(res.body).toStrictEqual([]); 26 | }); 27 | test('should count all cats', async () => { 28 | const res = await request(app).get('/api/cats/count'); 29 | expect(res.statusCode).toBe(200); 30 | expect(res.body).toStrictEqual(0); 31 | }); 32 | test('should create a new cat', async () => { 33 | const res = await request(app).post('/api/cat').send(newCat); 34 | expect(res.statusCode).toBe(201); 35 | expect(res.body).toMatchObject(newCat); 36 | catId = res.body._id; 37 | }); 38 | test('should get a cat by id', async () => { 39 | const res = await request(app).get(`/api/cat/${catId}`); 40 | expect(res.statusCode).toBe(200); 41 | expect(res.body).toMatchObject(newCat); 42 | }); 43 | test('should update a cat by id', async () => { 44 | const res = await request(app).put(`/api/cat/${catId}`).send({ weight: 5 }); 45 | expect(res.statusCode).toBe(200); 46 | }); 47 | test('should delete a cat by id', async () => { 48 | const res = await request(app).delete(`/api/cat/${catId}`); 49 | expect(res.statusCode).toBe(200); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /client/app/shared/toast/toast.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { ToastComponent } from './toast.component'; 5 | 6 | describe('Component: Toast', () => { 7 | let component: ToastComponent; 8 | let fixture: ComponentFixture; 9 | let compiled: HTMLElement; 10 | 11 | beforeEach(waitForAsync(() => { 12 | TestBed.configureTestingModule({ 13 | declarations: [ ToastComponent ], 14 | schemas: [NO_ERRORS_SCHEMA] 15 | }) 16 | .compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(ToastComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | compiled = fixture.nativeElement as HTMLElement; 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | 30 | it('should not have message set nor DOM element', () => { 31 | expect(component.message.body).toBeFalsy(); 32 | expect(component.message.type).toBeFalsy(); 33 | const div = compiled.querySelector('div'); 34 | expect(div).toBeNull(); 35 | }); 36 | 37 | it('should set the message and create the DOM element', () => { 38 | const mockMessage = { 39 | body: 'test message', 40 | type: 'warning' 41 | }; 42 | component.setMessage(mockMessage.body, mockMessage.type); 43 | expect(component.message.body).toBe(mockMessage.body); 44 | expect(component.message.type).toBe(mockMessage.type); 45 | fixture.detectChanges(); 46 | const div = compiled.querySelector('div'); 47 | expect(div).toBeDefined(); 48 | expect(div?.textContent).toContain(mockMessage.body); 49 | expect(div?.className).toContain(mockMessage.type); 50 | }); 51 | 52 | }); 53 | -------------------------------------------------------------------------------- /server/test/users.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import request from 'supertest'; 3 | import { describe, expect, test } from '@jest/globals'; 4 | process.env.NODE_ENV = 'test'; 5 | 6 | import { app } from '../app'; 7 | import { connectToMongo, disconnectMongo } from '../mongo'; 8 | 9 | const newUser = { username: 'Dave', email: 'dave@example.com', role: 'user' }; 10 | let userId = ''; 11 | 12 | beforeAll(async () => { 13 | await connectToMongo(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await request(app).delete('/api/users/delete'); 18 | await disconnectMongo(); 19 | }); 20 | 21 | describe('User tests', () => { 22 | test('should get all users', async () => { 23 | const res = await request(app).get('/api/users'); 24 | expect(res.statusCode).toBe(200); 25 | expect(res.body).toStrictEqual([]); 26 | }); 27 | test('should count all users', async () => { 28 | const res = await request(app).get('/api/users/count'); 29 | expect(res.statusCode).toBe(200); 30 | expect(res.body).toStrictEqual(0); 31 | }); 32 | test('should create a new user', async () => { 33 | const res = await request(app).post('/api/user').send(newUser); 34 | expect(res.statusCode).toBe(201); 35 | expect(res.body).toMatchObject(newUser); 36 | userId = res.body._id; 37 | }); 38 | test('should get a user by id', async () => { 39 | const res = await request(app).get(`/api/user/${userId}`); 40 | expect(res.statusCode).toBe(200); 41 | expect(res.body).toMatchObject(newUser); 42 | }); 43 | test('should update a user by id', async () => { 44 | const res = await request(app).put(`/api/user/${userId}`).send({ username: 'Dave2' }); 45 | expect(res.statusCode).toBe(200); 46 | }); 47 | test('should delete a user by id', async () => { 48 | const res = await request(app).delete(`/api/user/${userId}`); 49 | expect(res.statusCode).toBe(200); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /client/app/register/register.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |

Register

5 |
6 |
7 |
8 | 9 | 10 | 11 | 13 |
14 |
15 | 16 | 17 | 18 | 20 |
21 |
22 | 23 | 24 | 25 | 27 |
28 |
29 | 30 | 31 | 32 | 37 |
38 | 41 |
42 |
43 |
-------------------------------------------------------------------------------- /client/app/about/about.component.html: -------------------------------------------------------------------------------- 1 |
2 |

About

3 |
4 | 52 |
53 |
-------------------------------------------------------------------------------- /client/app/add-cat-form/add-cat-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { FormsModule, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; 3 | 4 | import { ToastComponent } from '../shared/toast/toast.component'; 5 | import { CatService } from '../services/cat.service'; 6 | import { AddCatFormComponent } from './add-cat-form.component'; 7 | 8 | class CatServiceMock { } 9 | 10 | describe('Component: AddCatForm', () => { 11 | let component: AddCatFormComponent; 12 | let fixture: ComponentFixture; 13 | let compiled: HTMLElement; 14 | 15 | beforeEach(waitForAsync(() => { 16 | TestBed.configureTestingModule({ 17 | imports: [FormsModule, ReactiveFormsModule], 18 | declarations: [ AddCatFormComponent ], 19 | providers: [ 20 | ToastComponent, UntypedFormBuilder, 21 | { provide: CatService, useClass: CatServiceMock } 22 | ] 23 | }) 24 | .compileComponents(); 25 | })); 26 | 27 | beforeEach(() => { 28 | fixture = TestBed.createComponent(AddCatFormComponent); 29 | component = fixture.componentInstance; 30 | fixture.detectChanges(); 31 | compiled = fixture.nativeElement as HTMLElement; 32 | }); 33 | 34 | it('should create', () => { 35 | expect(component).toBeTruthy(); 36 | }); 37 | 38 | it('should display header text', () => { 39 | const header = compiled.querySelector('.card-header'); 40 | expect(header?.textContent).toContain('Add new cat'); 41 | }); 42 | 43 | it('should display the add form', () => { 44 | const form = compiled.querySelector('form'); 45 | expect(form).toBeTruthy(); 46 | const inputs = compiled.querySelectorAll('input'); 47 | expect(inputs[0]).toBeTruthy(); 48 | expect(inputs[1]).toBeTruthy(); 49 | expect(inputs[2]).toBeTruthy(); 50 | expect(inputs[0].value).toBeFalsy(); 51 | expect(inputs[1].value).toBeFalsy(); 52 | expect(inputs[2].value).toBeFalsy(); 53 | const button = compiled.querySelector('button'); 54 | expect(button).toBeTruthy(); 55 | }); 56 | 57 | }); 58 | -------------------------------------------------------------------------------- /client/app/cats/cats.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | 3 | import { CatService } from '../services/cat.service'; 4 | import { ToastComponent } from '../shared/toast/toast.component'; 5 | import { Cat } from '../shared/models/cat.model'; 6 | 7 | @Component({ 8 | selector: 'app-cats', 9 | templateUrl: './cats.component.html', 10 | styleUrls: ['./cats.component.scss'], 11 | standalone: false 12 | }) 13 | export class CatsComponent implements OnInit { 14 | private catService = inject(CatService); 15 | toast = inject(ToastComponent); 16 | 17 | 18 | cat = new Cat(); 19 | cats: Cat[] = []; 20 | isLoading = true; 21 | isEditing = false; 22 | 23 | ngOnInit(): void { 24 | this.getCats(); 25 | } 26 | 27 | getCats(): void { 28 | this.catService.getCats().subscribe({ 29 | next: data => this.cats = data, 30 | error: error => console.log(error), 31 | complete: () => this.isLoading = false 32 | }); 33 | } 34 | 35 | enableEditing(cat: Cat): void { 36 | this.isEditing = true; 37 | this.cat = cat; 38 | } 39 | 40 | cancelEditing(): void { 41 | this.isEditing = false; 42 | this.cat = new Cat(); 43 | this.toast.setMessage('Item editing cancelled.', 'warning'); 44 | // reload the cats to reset the editing 45 | this.getCats(); 46 | } 47 | 48 | editCat(cat: Cat): void { 49 | this.catService.editCat(cat).subscribe({ 50 | next: () => { 51 | this.isEditing = false; 52 | this.cat = cat; 53 | this.toast.setMessage('Item edited successfully.', 'success'); 54 | }, 55 | error: error => console.log(error) 56 | }); 57 | } 58 | 59 | deleteCat(cat: Cat): void { 60 | if (window.confirm('Are you sure you want to permanently delete this item?')) { 61 | this.catService.deleteCat(cat).subscribe({ 62 | next: () => { 63 | this.cats = this.cats.filter(elem => elem._id !== cat._id); 64 | this.toast.setMessage('Item deleted successfully.', 'success'); 65 | }, 66 | error: error => console.log(error) 67 | }); 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /client/app/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | 4 | import { JwtHelperService } from '@auth0/angular-jwt'; 5 | 6 | import { UserService } from './user.service'; 7 | import { ToastComponent } from '../shared/toast/toast.component'; 8 | import { User } from '../shared/models/user.model'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | private userService = inject(UserService); 13 | private router = inject(Router); 14 | private jwtHelper = inject(JwtHelperService); 15 | toast = inject(ToastComponent); 16 | 17 | loggedIn = false; 18 | isAdmin = false; 19 | 20 | currentUser: User = new User(); 21 | 22 | constructor() { 23 | const token = localStorage.getItem('token'); 24 | if (token) { 25 | const decodedUser = this.decodeUserFromToken(token); 26 | this.setCurrentUser(decodedUser); 27 | } 28 | } 29 | 30 | login(emailAndPassword: { email: string; password: string }): void { 31 | this.userService.login(emailAndPassword).subscribe({ 32 | next: res => { 33 | localStorage.setItem('token', res.token); 34 | const decodedUser = this.decodeUserFromToken(res.token); 35 | this.setCurrentUser(decodedUser); 36 | this.loggedIn = true; 37 | this.router.navigate(['/']); 38 | }, 39 | error: () => this.toast.setMessage('Invalid email or password!', 'danger') 40 | }); 41 | } 42 | 43 | logout(): void { 44 | localStorage.removeItem('token'); 45 | this.loggedIn = false; 46 | this.isAdmin = false; 47 | this.currentUser = new User(); 48 | this.router.navigate(['/']); 49 | } 50 | 51 | decodeUserFromToken(token: string): object { 52 | return this.jwtHelper.decodeToken(token).user; 53 | } 54 | 55 | setCurrentUser(decodedUser: User): void { 56 | this.loggedIn = true; 57 | this.currentUser._id = decodedUser._id; 58 | this.currentUser.username = decodedUser.username; 59 | this.currentUser.role = decodedUser.role; 60 | this.isAdmin = decodedUser.role === 'admin'; 61 | delete decodedUser.role; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /client/app/app.module.ts: -------------------------------------------------------------------------------- 1 | // Angular 2 | import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 3 | import { JwtModule } from '@auth0/angular-jwt'; 4 | // Modules 5 | import { AppRoutingModule } from './app-routing.module'; 6 | import { SharedModule } from './shared/shared.module'; 7 | // Services 8 | import { CatService } from './services/cat.service'; 9 | import { UserService } from './services/user.service'; 10 | import { AuthService } from './services/auth.service'; 11 | import { AuthGuardLogin } from './services/auth-guard-login.service'; 12 | import { AuthGuardAdmin } from './services/auth-guard-admin.service'; 13 | // Components 14 | import { AppComponent } from './app.component'; 15 | import { CatsComponent } from './cats/cats.component'; 16 | import { AddCatFormComponent } from './add-cat-form/add-cat-form.component'; 17 | import { AboutComponent } from './about/about.component'; 18 | import { RegisterComponent } from './register/register.component'; 19 | import { LoginComponent } from './login/login.component'; 20 | import { LogoutComponent } from './logout/logout.component'; 21 | import { AccountComponent } from './account/account.component'; 22 | import { AdminComponent } from './admin/admin.component'; 23 | import { NotFoundComponent } from './not-found/not-found.component'; 24 | 25 | @NgModule({ 26 | declarations: [ 27 | AppComponent, 28 | CatsComponent, 29 | AddCatFormComponent, 30 | AboutComponent, 31 | RegisterComponent, 32 | LoginComponent, 33 | LogoutComponent, 34 | AccountComponent, 35 | AdminComponent, 36 | NotFoundComponent 37 | ], 38 | imports: [ 39 | AppRoutingModule, 40 | SharedModule, 41 | JwtModule.forRoot({ 42 | config: { 43 | tokenGetter: (): string | null => localStorage.getItem('token'), 44 | // allowedDomains: ['localhost:3000', 'localhost:4200'] 45 | } 46 | }) 47 | ], 48 | providers: [ 49 | AuthService, 50 | AuthGuardLogin, 51 | AuthGuardAdmin, 52 | CatService, 53 | UserService 54 | ], 55 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 56 | bootstrap: [AppComponent] 57 | }) 58 | 59 | export class AppModule { } 60 | -------------------------------------------------------------------------------- /client/app/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { FormsModule, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; 4 | import { Router } from '@angular/router'; 5 | 6 | import { ToastComponent } from '../shared/toast/toast.component'; 7 | import { AuthService } from '../services/auth.service'; 8 | import { LoginComponent } from './login.component'; 9 | 10 | class AuthServiceMock { } 11 | class RouterMock { } 12 | 13 | describe('Component: Login', () => { 14 | let component: LoginComponent; 15 | let fixture: ComponentFixture; 16 | let compiled: HTMLElement; 17 | 18 | beforeEach(waitForAsync(() => { 19 | TestBed.configureTestingModule({ 20 | imports: [ FormsModule, ReactiveFormsModule ], 21 | declarations: [ LoginComponent ], 22 | providers: [ 23 | UntypedFormBuilder, ToastComponent, 24 | { provide: Router, useClass: RouterMock }, 25 | { provide: AuthService, useClass: AuthServiceMock } 26 | ], 27 | schemas: [NO_ERRORS_SCHEMA] 28 | }) 29 | .compileComponents(); 30 | })); 31 | 32 | beforeEach(() => { 33 | fixture = TestBed.createComponent(LoginComponent); 34 | component = fixture.componentInstance; 35 | fixture.detectChanges(); 36 | compiled = fixture.nativeElement as HTMLElement; 37 | }); 38 | 39 | it('should create', () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | 43 | it('should display the page header text', () => { 44 | const header = compiled.querySelector('.card-header'); 45 | expect(header?.textContent).toContain('Login'); 46 | }); 47 | 48 | it('should display the username and password inputs', () => { 49 | const inputs = compiled.querySelectorAll('input'); 50 | expect(inputs[0]).toBeTruthy(); 51 | expect(inputs[1]).toBeTruthy(); 52 | expect(inputs[0].value).toBeFalsy(); 53 | expect(inputs[1].value).toBeFalsy(); 54 | }); 55 | 56 | it('should display the login button', () => { 57 | const button = compiled.querySelector('button'); 58 | expect(button).toBeTruthy(); 59 | expect(button?.textContent).toContain('Login'); 60 | expect(button?.disabled).toBeTruthy(); 61 | }); 62 | 63 | }); 64 | -------------------------------------------------------------------------------- /client/app/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; 4 | 5 | import { UserService } from '../services/user.service'; 6 | import { ToastComponent } from '../shared/toast/toast.component'; 7 | 8 | @Component({ 9 | selector: 'app-register', 10 | templateUrl: './register.component.html', 11 | standalone: false 12 | }) 13 | export class RegisterComponent { 14 | private formBuilder = inject(UntypedFormBuilder); 15 | private router = inject(Router); 16 | toast = inject(ToastComponent); 17 | private userService = inject(UserService); 18 | 19 | 20 | registerForm: UntypedFormGroup; 21 | username = new UntypedFormControl('', [ 22 | Validators.required, 23 | Validators.minLength(2), 24 | Validators.maxLength(30), 25 | Validators.pattern('[a-zA-Z0-9_-\\s]*') 26 | ]); 27 | email = new UntypedFormControl('', [ 28 | Validators.email, 29 | Validators.required, 30 | Validators.minLength(3), 31 | Validators.maxLength(100) 32 | ]); 33 | password = new UntypedFormControl('', [ 34 | Validators.required, 35 | Validators.minLength(6) 36 | ]); 37 | role = new UntypedFormControl('', [ 38 | Validators.required 39 | ]); 40 | 41 | constructor() { 42 | this.registerForm = this.formBuilder.group({ 43 | username: this.username, 44 | email: this.email, 45 | password: this.password, 46 | role: this.role 47 | }); 48 | } 49 | 50 | setClassUsername(): object { 51 | return { 'has-danger': !this.username.pristine && !this.username.valid }; 52 | } 53 | 54 | setClassEmail(): object { 55 | return { 'has-danger': !this.email.pristine && !this.email.valid }; 56 | } 57 | 58 | setClassPassword(): object { 59 | return { 'has-danger': !this.password.pristine && !this.password.valid }; 60 | } 61 | 62 | register(): void { 63 | this.userService.register(this.registerForm.value).subscribe({ 64 | next: () => { 65 | this.toast.setMessage('You successfully registered!', 'success'); 66 | this.router.navigate(['/login']); 67 | }, 68 | error: () => this.toast.setMessage('Email already exists', 'danger') 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/app/register/register.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | import { Router } from '@angular/router'; 5 | 6 | import { ToastComponent } from '../shared/toast/toast.component'; 7 | import { UserService } from '../services/user.service'; 8 | import { RegisterComponent } from './register.component'; 9 | 10 | class RouterMock { } 11 | class UserServiceMock { } 12 | 13 | describe('Component: Register', () => { 14 | let component: RegisterComponent; 15 | let fixture: ComponentFixture; 16 | let compiled: HTMLElement; 17 | 18 | beforeEach(waitForAsync(() => { 19 | TestBed.configureTestingModule({ 20 | imports: [ FormsModule, ReactiveFormsModule ], 21 | declarations: [ RegisterComponent ], 22 | providers: [ 23 | ToastComponent, 24 | { provide: Router, useClass: RouterMock }, 25 | { provide: UserService, useClass: UserServiceMock } 26 | ], 27 | schemas: [NO_ERRORS_SCHEMA] 28 | }) 29 | .compileComponents(); 30 | })); 31 | 32 | beforeEach(() => { 33 | fixture = TestBed.createComponent(RegisterComponent); 34 | component = fixture.componentInstance; 35 | fixture.detectChanges(); 36 | compiled = fixture.nativeElement as HTMLElement; 37 | }); 38 | 39 | it('should create', () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | 43 | it('should display the page header text', () => { 44 | const header = compiled.querySelector('.card-header'); 45 | expect(header?.textContent).toContain('Register'); 46 | }); 47 | 48 | it('should display the username, email and password inputs', () => { 49 | const inputs = compiled.querySelectorAll('input'); 50 | expect(inputs[0]).toBeTruthy(); 51 | expect(inputs[1]).toBeTruthy(); 52 | expect(inputs[2]).toBeTruthy(); 53 | expect(inputs[0].value).toBeFalsy(); 54 | expect(inputs[1].value).toBeFalsy(); 55 | expect(inputs[2].value).toBeFalsy(); 56 | }); 57 | 58 | it('should display the register button', () => { 59 | const button = compiled.querySelector('button'); 60 | expect(button).toBeTruthy(); 61 | expect(button?.textContent).toContain('Register'); 62 | expect(button?.disabled).toBeTruthy(); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /client/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 59 | 60 | 61 | 62 |
-------------------------------------------------------------------------------- /server/controllers/base.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { Model } from 'mongoose'; 3 | 4 | abstract class BaseCtrl { 5 | 6 | abstract model:Model 7 | 8 | // Get all 9 | getAll = async (req: Request, res: Response) => { 10 | try { 11 | const docs = await this.model.find({}); 12 | return res.status(200).json(docs); 13 | } catch (err) { 14 | return res.status(400).json({ error: (err as Error).message }); 15 | } 16 | }; 17 | 18 | // Count all 19 | count = async (req: Request, res: Response) => { 20 | try { 21 | const count = await this.model.countDocuments(); 22 | return res.status(200).json(count); 23 | } catch (err) { 24 | return res.status(400).json({ error: (err as Error).message }); 25 | } 26 | }; 27 | 28 | // Insert 29 | insert = async (req: Request, res: Response) => { 30 | try { 31 | const obj = await new this.model(req.body).save(); 32 | return res.status(201).json(obj); 33 | } catch (err) { 34 | return res.status(400).json({ error: (err as Error).message }); 35 | } 36 | }; 37 | 38 | // Get by id 39 | get = async (req: Request, res: Response) => { 40 | try { 41 | const obj = await this.model.findOne({ _id: req.params.id }); 42 | return res.status(200).json(obj); 43 | } catch (err) { 44 | return res.status(500).json({ error: (err as Error).message }); 45 | } 46 | }; 47 | 48 | // Update by id 49 | update = async (req: Request, res: Response) => { 50 | try { 51 | await this.model.findOneAndUpdate({ _id: req.params.id }, req.body); 52 | return res.sendStatus(200); 53 | } catch (err) { 54 | return res.status(400).json({ error: (err as Error).message }); 55 | } 56 | }; 57 | 58 | // Delete by id 59 | delete = async (req: Request, res: Response) => { 60 | try { 61 | await this.model.findOneAndDelete({ _id: req.params.id }); 62 | return res.sendStatus(200); 63 | } catch (err) { 64 | return res.status(400).json({ error: (err as Error).message }); 65 | } 66 | }; 67 | 68 | // Drop collection (for tests) 69 | deleteAll = async (_req: Request, res: Response) => { 70 | try { 71 | await this.model.deleteMany(); 72 | return res.sendStatus(200); 73 | } catch (err) { 74 | return res.status(400).json({ error: (err as Error).message }); 75 | } 76 | }; 77 | } 78 | 79 | export default BaseCtrl; 80 | -------------------------------------------------------------------------------- /client/app/account/account.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { FormsModule } from '@angular/forms'; 4 | 5 | import { ToastComponent } from '../shared/toast/toast.component'; 6 | import { User } from '../shared/models/user.model'; 7 | import { AuthService } from '../services/auth.service'; 8 | import { UserService } from '../services/user.service'; 9 | import { AccountComponent } from './account.component'; 10 | import { of, Observable } from 'rxjs'; 11 | 12 | class AuthServiceMock { } 13 | 14 | class UserServiceMock { 15 | mockUser = { 16 | username: 'Test user', 17 | email: 'test@example.com', 18 | role: 'user' 19 | }; 20 | getUser(): Observable { 21 | return of(this.mockUser); 22 | } 23 | } 24 | 25 | describe('Component: Account', () => { 26 | let component: AccountComponent; 27 | let fixture: ComponentFixture; 28 | let compiled: HTMLElement; 29 | 30 | beforeEach(waitForAsync(() => { 31 | TestBed.configureTestingModule({ 32 | imports: [ FormsModule ], 33 | declarations: [ AccountComponent ], 34 | providers: [ 35 | ToastComponent, 36 | { provide: AuthService, useClass: AuthServiceMock }, 37 | { provide: UserService, useClass: UserServiceMock }, 38 | ], 39 | schemas: [NO_ERRORS_SCHEMA] 40 | }) 41 | .compileComponents(); 42 | })); 43 | 44 | beforeEach(() => { 45 | fixture = TestBed.createComponent(AccountComponent); 46 | component = fixture.componentInstance; 47 | fixture.detectChanges(); 48 | component.user = { 49 | username: 'Test user', 50 | email: 'test@example.com' 51 | }; 52 | fixture.detectChanges(); 53 | compiled = fixture.nativeElement as HTMLElement; 54 | }); 55 | 56 | it('should create', () => { 57 | expect(component).toBeTruthy(); 58 | }); 59 | 60 | it('should display the page header text', () => { 61 | const header = compiled.querySelector('.card-header'); 62 | expect(header?.textContent).toContain('Account settings'); 63 | }); 64 | 65 | it('should display the username and email inputs filled', async () => { 66 | await fixture.whenStable(); 67 | const inputs = compiled.querySelectorAll('input'); 68 | expect(inputs[0].value).toContain('Test user'); 69 | expect(inputs[1].value).toContain('test@example.com'); 70 | }); 71 | 72 | it('should display the save button enabled', () => { 73 | const button = compiled.querySelector('button'); 74 | expect(button).toBeTruthy(); 75 | expect(button?.disabled).toBeFalsy(); 76 | }); 77 | 78 | }); 79 | -------------------------------------------------------------------------------- /client/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | 4 | import { AuthService } from './services/auth.service'; 5 | import { AppComponent } from './app.component'; 6 | 7 | class AuthServiceMock { } 8 | 9 | describe('Component: App', () => { 10 | let component: AppComponent; 11 | let fixture: ComponentFixture; 12 | let authService: AuthService; 13 | let compiled: HTMLElement; 14 | 15 | beforeEach(waitForAsync(() => { 16 | TestBed.configureTestingModule({ 17 | imports: [ RouterTestingModule ], 18 | declarations: [ AppComponent ], 19 | providers: [ { provide: AuthService, useClass: AuthServiceMock } ], 20 | }).compileComponents(); 21 | })); 22 | 23 | beforeEach(() => { 24 | fixture = TestBed.createComponent(AppComponent); 25 | component = fixture.componentInstance; 26 | authService = fixture.debugElement.injector.get(AuthService); 27 | fixture.detectChanges(); 28 | compiled = fixture.nativeElement as HTMLElement; 29 | }); 30 | 31 | it('should create the app', waitForAsync(() => { 32 | expect(component).toBeTruthy(); 33 | })); 34 | 35 | it('should display the navigation bar correctly for guests', () => { 36 | const elems = compiled.querySelectorAll('.nav-link'); 37 | expect(elems.length).toBe(4); 38 | expect(elems[0].textContent).toContain('Home'); 39 | expect(elems[1].textContent).toContain('Cats'); 40 | expect(elems[2].textContent).toContain('Login'); 41 | expect(elems[3].textContent).toContain('Register'); 42 | }); 43 | 44 | it('should display the navigation bar correctly for logged users', () => { 45 | authService.loggedIn = true; 46 | authService.currentUser = { _id: '123', username: 'Tester', role: 'user' }; 47 | fixture.detectChanges(); 48 | const elems = compiled.querySelectorAll('.nav-link'); 49 | expect(elems.length).toBe(4); 50 | expect(elems[0].textContent).toContain('Home'); 51 | expect(elems[1].textContent).toContain('Cats'); 52 | expect(elems[2].textContent).toContain('Account (Tester)'); 53 | expect(elems[3].textContent).toContain('Logout'); 54 | }); 55 | 56 | it('should display the navigation bar correctly for admin users', () => { 57 | authService.loggedIn = true; 58 | authService.isAdmin = true; 59 | authService.currentUser = { _id: '123', username: 'Tester', role: 'admin' }; 60 | fixture.detectChanges(); 61 | const elems = compiled.querySelectorAll('.nav-link'); 62 | expect(elems.length).toBe(5); 63 | expect(elems[0].textContent).toContain('Home'); 64 | expect(elems[1].textContent).toContain('Cats'); 65 | expect(elems[2].textContent).toContain('Account (Tester)'); 66 | expect(elems[3].textContent).toContain('Admin'); 67 | expect(elems[4].textContent).toContain('Logout'); 68 | }); 69 | 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-full-stack", 3 | "version": "20.0.4", 4 | "license": "MIT", 5 | "author": "Davide Violante", 6 | "description": "Angular Full Stack project built using Angular 2+, Express, Mongoose and Node.", 7 | "engines": { 8 | "node": ">=20", 9 | "npm": ">=10" 10 | }, 11 | "scripts": { 12 | "ng": "ng", 13 | "build:dev": "ng build -c development && tsc -p server", 14 | "build": "ng build && tsc -p server", 15 | "start": "node dist/server/app.js", 16 | "predev": "tsc -p server", 17 | "dev": "concurrently \"mongod\" \"ng serve --open\" \"tsc -w -p server\" \"nodemon dist/server/app.js\"", 18 | "prod": "concurrently \"mongod\" \"ng build && tsc -p server && node dist/server/app.js\"", 19 | "test": "ng test", 20 | "test:be": "tsc -p server && jest", 21 | "test:becov": "tsc -p server && jest --coverage", 22 | "lint": "ng lint && htmlhint \"client/**/*.html\" && sass-lint \"client/**/*.scss\" -v", 23 | "lint:fix": "ng lint --fix", 24 | "prepare": "husky || true" 25 | }, 26 | "private": true, 27 | "dependencies": { 28 | "@angular/animations": "^20.0.4", 29 | "@angular/common": "^20.0.4", 30 | "@angular/compiler": "^20.0.4", 31 | "@angular/core": "^20.0.4", 32 | "@angular/forms": "^20.0.4", 33 | "@angular/platform-browser": "^20.0.4", 34 | "@angular/platform-browser-dynamic": "^20.0.4", 35 | "@angular/router": "^20.0.4", 36 | "@auth0/angular-jwt": "^5.2.0", 37 | "bcryptjs": "^2.4.3", 38 | "bootstrap": "^5.3.7", 39 | "dotenv": "^16.5.0", 40 | "express": "^4.21.2", 41 | "font-awesome": "^4.7.0", 42 | "jsonwebtoken": "^9.0.2", 43 | "mongoose": "^8.16.0", 44 | "morgan": "^1.10.0", 45 | "rxjs": "~7.8.0", 46 | "tslib": "^2.3.0", 47 | "zone.js": "~0.15.0" 48 | }, 49 | "devDependencies": { 50 | "@angular/build": "^20.0.3", 51 | "@angular/cli": "^20.0.3", 52 | "@angular/compiler-cli": "^20.0.4", 53 | "@angular/language-service": "^20.0.4", 54 | "@types/bcryptjs": "^2.4.6", 55 | "@types/express": "^4.17.23", 56 | "@types/jasmine": "~5.1.0", 57 | "@types/jest": "^29.5.14", 58 | "@types/jsonwebtoken": "^9.0.10", 59 | "@types/morgan": "^1.9.10", 60 | "@types/node": "^22.15.29", 61 | "@types/supertest": "^6.0.3", 62 | "angular-eslint": "20.1.0", 63 | "concurrently": "^9.1.2", 64 | "eslint": "^9.28.0", 65 | "htmlhint": "^1.6.3", 66 | "husky": "^9.1.7", 67 | "jasmine-core": "~5.2.0", 68 | "jest": "^29.7.0", 69 | "karma": "~6.4.0", 70 | "karma-chrome-launcher": "~3.2.0", 71 | "karma-coverage": "~2.2.0", 72 | "karma-jasmine": "~5.1.0", 73 | "karma-jasmine-html-reporter": "^2.1.0", 74 | "nodemon": "^3.1.10", 75 | "sass-lint": "^1.13.1", 76 | "supertest": "^7.1.1", 77 | "ts-jest": "^29.4.0", 78 | "typescript": "~5.8.3", 79 | "typescript-eslint": "^8.33.1" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/app/admin/admin.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { ToastComponent } from '../shared/toast/toast.component'; 5 | import { AuthService } from '../services/auth.service'; 6 | import { UserService } from '../services/user.service'; 7 | import { AdminComponent } from './admin.component'; 8 | import { of, Observable } from 'rxjs'; 9 | 10 | class AuthServiceMock { 11 | currentUser = { _id: '1', username: 'test1@example.com', role: 'admin' }; 12 | } 13 | 14 | class UserServiceMock { 15 | mockUsers = [ 16 | { _id: '1', username: 'Test 1', email: 'test1@example.com', role: 'admin' }, 17 | { _id: '2', username: 'Test 2', email: 'test2@example.com', role: 'user' }, 18 | ]; 19 | getUsers(): Observable<{_id: string; username: string; email: string; role: string;}[]> { 20 | return of(this.mockUsers); 21 | } 22 | } 23 | 24 | describe('Component: Admin', () => { 25 | let component: AdminComponent; 26 | let fixture: ComponentFixture; 27 | let compiled: HTMLElement; 28 | 29 | beforeEach(waitForAsync(() => { 30 | TestBed.configureTestingModule({ 31 | declarations: [ AdminComponent ], 32 | providers: [ 33 | ToastComponent, 34 | { provide: AuthService, useClass: AuthServiceMock }, 35 | { provide: UserService, useClass: UserServiceMock }, 36 | ], 37 | schemas: [NO_ERRORS_SCHEMA] 38 | }) 39 | .compileComponents(); 40 | })); 41 | 42 | beforeEach(() => { 43 | fixture = TestBed.createComponent(AdminComponent); 44 | component = fixture.componentInstance; 45 | fixture.detectChanges(); 46 | compiled = fixture.nativeElement as HTMLElement; 47 | }); 48 | 49 | it('should create', () => { 50 | expect(component).toBeTruthy(); 51 | }); 52 | 53 | it('should display the page header text', () => { 54 | const header = compiled.querySelector('.card-header'); 55 | expect(header?.textContent).toContain('Registered users (2)'); 56 | }); 57 | 58 | it('should display the text for no users', () => { 59 | component.users = []; 60 | fixture.detectChanges(); 61 | const header = compiled.querySelector('h4'); 62 | expect(header?.textContent).toContain('Registered users (0)'); 63 | const td = compiled.querySelector('td'); 64 | expect(td?.textContent).toContain('There are no registered users'); 65 | }); 66 | 67 | it('should display registered users', () => { 68 | const tds = compiled.querySelectorAll('td'); 69 | expect(tds[0].textContent).toContain('Test 1'); 70 | expect(tds[1].textContent).toContain('test1@example.com'); 71 | expect(tds[2].textContent).toContain('admin'); 72 | expect(tds[4].textContent).toContain('Test 2'); 73 | expect(tds[5].textContent).toContain('test2@example.com'); 74 | expect(tds[6].textContent).toContain('user'); 75 | }); 76 | 77 | it('should display the delete buttons', () => { 78 | const buttons = compiled.querySelectorAll('button'); 79 | expect(buttons[0].disabled).toBeTruthy(); 80 | expect(buttons[0].textContent).toContain('Delete'); 81 | expect(buttons[1].disabled).toBeFalsy(); 82 | expect(buttons[1].textContent).toContain('Delete'); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /client/app/cats/cats.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @if (!isLoading) { 6 |
7 |

Current cats ({{cats.length}})

8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | @if (cats.length === 0) { 19 | 20 | 21 | 22 | 23 | 24 | } 25 | @if (!isEditing) { 26 | 27 | @for (cat of cats; track cat) { 28 | 29 | 30 | 31 | 32 | 40 | 41 | } 42 | 43 | } 44 | @if (isEditing) { 45 | 46 | 47 | 73 | 74 | 75 | } 76 |
NameAgeWeightActions
There are no cats in the DB. Add a new cat below.
{{cat.name}}{{cat.age}}{{cat.weight}} 33 | 36 | 39 |
48 |
49 |
50 |
51 | 53 |
54 |
55 | 57 |
58 |
59 | 61 |
62 |
63 | 66 | 69 |
70 |
71 |
72 |
77 |
78 |
79 | } 80 | 81 | @if (!isEditing) { 82 | 83 | } -------------------------------------------------------------------------------- /client/app/cats/cats.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; 2 | import { waitForAsync, TestBed, ComponentFixture } from '@angular/core/testing'; 3 | import { RouterTestingModule } from '@angular/router/testing'; 4 | import { FormsModule, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; 5 | 6 | import { ToastComponent } from '../shared/toast/toast.component'; 7 | import { CatService } from '../services/cat.service'; 8 | import { CatsComponent } from './cats.component'; 9 | import { of, Observable } from 'rxjs'; 10 | 11 | class CatServiceMock { 12 | mockCats = [ 13 | { name: 'Cat 1', age: 1, weight: 2 }, 14 | { name: 'Cat 2', age: 3, weight: 4.2 }, 15 | ]; 16 | getCats(): Observable<{name: string; age: number; weight: number}[]> { 17 | return of(this.mockCats); 18 | } 19 | } 20 | 21 | describe('Component: Cats', () => { 22 | let component: CatsComponent; 23 | let fixture: ComponentFixture; 24 | let compiled: HTMLElement; 25 | 26 | beforeEach(waitForAsync(() => { 27 | TestBed.configureTestingModule({ 28 | imports: [ RouterTestingModule, FormsModule, ReactiveFormsModule ], 29 | declarations: [ CatsComponent ], 30 | providers: [ 31 | ToastComponent, UntypedFormBuilder, 32 | { provide: CatService, useClass: CatServiceMock } 33 | ], 34 | schemas: [CUSTOM_ELEMENTS_SCHEMA] 35 | }) 36 | .compileComponents(); 37 | })); 38 | 39 | beforeEach(() => { 40 | fixture = TestBed.createComponent(CatsComponent); 41 | component = fixture.componentInstance; 42 | fixture.detectChanges(); 43 | compiled = fixture.nativeElement as HTMLElement; 44 | }); 45 | 46 | it('should create', () => { 47 | expect(component).toBeTruthy(); 48 | }); 49 | 50 | it('should display the page header text', () => { 51 | const header = compiled.querySelector('.card-header'); 52 | expect(header?.textContent).toContain('Current cats (2)'); 53 | }); 54 | 55 | it('should display the text for no cats', () => { 56 | component.cats = []; 57 | fixture.detectChanges(); 58 | const header = compiled.querySelector('.card-header'); 59 | expect(header?.textContent).toContain('Current cats (0)'); 60 | const td = compiled.querySelector('td'); 61 | expect(td?.textContent).toContain('There are no cats in the DB. Add a new cat below.'); 62 | }); 63 | 64 | it('should display current cats', () => { 65 | const tds = compiled.querySelectorAll('td'); 66 | expect(tds.length).toBe(8); 67 | expect(tds[0].textContent).toContain('Cat 1'); 68 | expect(tds[1].textContent).toContain('1'); 69 | expect(tds[2].textContent).toContain('2'); 70 | expect(tds[4].textContent).toContain('Cat 2'); 71 | expect(tds[5].textContent).toContain('3'); 72 | expect(tds[6].textContent).toContain('4.2'); 73 | }); 74 | 75 | it('should display the edit and delete buttons', () => { 76 | const buttons = compiled.querySelectorAll('button'); 77 | expect(buttons[0].textContent).toContain('Edit'); 78 | expect(buttons[1].textContent).toContain('Delete'); 79 | expect(buttons[2].textContent).toContain('Edit'); 80 | expect(buttons[3].textContent).toContain('Delete'); 81 | }); 82 | 83 | it('should display the edit form', async () => { 84 | component.isEditing = true; 85 | component.cat = { name: 'Cat 1', age: 1, weight: 2 }; 86 | fixture.detectChanges(); 87 | await fixture.whenStable(); 88 | const tds = compiled.querySelectorAll('td'); 89 | expect(tds.length).toBe(1); 90 | const form = compiled.querySelector('form'); 91 | expect(form).toBeTruthy(); 92 | const inputs = compiled.querySelectorAll('input'); 93 | expect(inputs[0].value).toContain('Cat 1'); 94 | expect(inputs[1].value).toContain('1'); 95 | expect(inputs[2].value).toContain('2'); 96 | const buttons = compiled.querySelectorAll('button'); 97 | expect(buttons[0].textContent).toContain('Save'); 98 | expect(buttons[1].textContent).toContain('Cancel'); 99 | }); 100 | 101 | }); 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular Full Stack 2 | [![](https://github.com/davideviolante/Angular-Full-Stack/workflows/Build/badge.svg)](https://github.com/DavideViolante/Angular-Full-Stack/actions?query=workflow%3ABuild) [![](https://github.com/davideviolante/Angular-Full-Stack/workflows/Tests/badge.svg)](https://github.com/DavideViolante/Angular-Full-Stack/actions?query=workflow%3ATests) [![Donate](https://img.shields.io/badge/paypal-donate-179BD7.svg)](https://www.paypal.me/dviolante) 3 | 4 | 5 | Angular Full Stack is a project to easly get started with the latest Angular using a real backend and database. Whole stack is in TypeScript, from frontend to backend, giving you the advantage to code in one single language throughout the all stack. 6 | 7 | This project uses the [MEAN stack](https://en.wikipedia.org/wiki/MEAN_(software_bundle)): 8 | * [**M**ongoose.js](http://www.mongoosejs.com) ([MongoDB](https://www.mongodb.com)): database 9 | * [**E**xpress.js](http://expressjs.com): backend framework 10 | * [**A**ngular 2+](https://angular.io): frontend framework 11 | * [**N**ode.js](https://nodejs.org): runtime environment 12 | 13 | Other tools and technologies used: 14 | * [Angular CLI](https://cli.angular.io): frontend scaffolding 15 | * [Bootstrap](http://www.getbootstrap.com): layout and styles 16 | * [Font Awesome](http://fontawesome.com): icons 17 | * [JSON Web Token](https://jwt.io): user authentication 18 | * [Angular 2 JWT](https://github.com/auth0/angular2-jwt): JWT helper for Angular 2+ 19 | * [Bcrypt.js](https://github.com/dcodeIO/bcrypt.js): password encryption 20 | 21 | ## Prerequisites 22 | 1. Install [Node.js](https://nodejs.org) and [MongoDB](https://www.mongodb.com) 23 | 2. Install Angular CLI: `npm i -g @angular/cli` 24 | 3. From project root folder install all the dependencies: `npm i` 25 | 26 | ## Run 27 | ### Development mode with files watching 28 | `npm run dev`: [concurrently](https://github.com/kimmobrunfeldt/concurrently) execute MongoDB, Angular build, TypeScript compiler and Express server. 29 | 30 | A window will automatically open at [localhost:4200](http://localhost:4200). Angular and Express files are being watched. Any change automatically creates a new bundle, restart Express server and reload your browser. 31 | 32 | ### Production mode 33 | `npm run prod`: run the project with a production bundle listening at [localhost:3000](http://localhost:3000) 34 | 35 | ### Manual mode 36 | 1. Build frontend: `npm run build:dev` for dev or `npm run build` for prod 37 | 2. Build backend: `npm run predev` 38 | 3. Run MongoDB: `mongod` 39 | 4. Run the app: `npm start` 40 | 41 | ### Docker 42 | 1. `sudo docker-compose up` 43 | 2. Go to [localhost:3000](http://localhost:3000) 44 | 45 | ### AWS EC2 46 | 1. Create a EC2 Linux machine on AWS 47 | 2. Edit the EC2 Security Group and add TCP port `3000` as an Inbound rule for Source `0.0.0.0/0` 48 | 3. Clone this repo into the EC2 machine 49 | 4. If you use a remote MongoDB instance, edit `.env` file 50 | 5. Run `npm ci` 51 | 6. Run `npm run build` 52 | 7. Run `npm start` 53 | 8. The app is now running and listening on port 3000 54 | 9. You can now visit the public IP of your AWS EC2 followed by the port, eg: `12.34.56.78:3000` 55 | 10. Tip: use [pm2](https://pm2.keymetrics.io/) to run the app instead of `npm start`, eg: `pm2 start dist/server/app.js` 56 | 57 | ## Preview 58 | ![Preview](https://raw.githubusercontent.com/DavideViolante/Angular2-Full-Stack/master/demo.gif "Preview") 59 | 60 | ## Please open an issue if 61 | * you have any suggestion to improve this project 62 | * you noticed any problem or error 63 | 64 | ## Running tests 65 | Run `ng test` to execute the frontend unit tests via [Karma](https://karma-runner.github.io). 66 | 67 | Run `npm run test:be` to execute the backend tests via [Jest](https://jestjs.io/) (it requires `mongod` already running). 68 | 69 | ## Running linters 70 | Run `npm run lint` to execute [Angular ESLint](https://github.com/angular-eslint/angular-eslint), [HTML linting](https://github.com/htmlhint/HTMLHint) and [SASS linting](https://github.com/sasstools/sass-lint). 71 | 72 | ## Wiki 73 | To get more help about this project, [visit the official wiki](https://github.com/DavideViolante/Angular-Full-Stack/wiki). 74 | 75 | ## Further help 76 | To get more help on the `angular-cli` use `ng --help` or go check out the [Angular-CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 77 | 78 | ### Author 79 | * [Davide Violante](https://github.com/DavideViolante) 80 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular2-full-stack": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "client", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular/build:application", 19 | "options": { 20 | "outputPath": "dist/public", 21 | "index": "client/index.html", 22 | "browser": "client/main.ts", 23 | "polyfills": [ 24 | "zone.js" 25 | ], 26 | "tsConfig": "tsconfig.app.json", 27 | "inlineStyleLanguage": "scss", 28 | "assets": [ 29 | "client/assets/favicon.ico", 30 | "client/assets" 31 | ], 32 | "styles": [ 33 | "node_modules/font-awesome/css/font-awesome.min.css", 34 | "client/styles.scss" 35 | ], 36 | "scripts": [ 37 | "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" 38 | ] 39 | }, 40 | "configurations": { 41 | "production": { 42 | "budgets": [ 43 | { 44 | "type": "initial", 45 | "maximumWarning": "750kB", 46 | "maximumError": "1MB" 47 | }, 48 | { 49 | "type": "anyComponentStyle", 50 | "maximumWarning": "2kB", 51 | "maximumError": "4kB" 52 | } 53 | ], 54 | "outputHashing": "all" 55 | }, 56 | "development": { 57 | "optimization": false, 58 | "extractLicenses": false, 59 | "sourceMap": true 60 | } 61 | }, 62 | "defaultConfiguration": "production" 63 | }, 64 | "serve": { 65 | "builder": "@angular/build:dev-server", 66 | "configurations": { 67 | "production": { 68 | "buildTarget": "angular2-full-stack:build:production" 69 | }, 70 | "development": { 71 | "proxyConfig": "proxy.conf.json", 72 | "buildTarget": "angular2-full-stack:build:development" 73 | } 74 | }, 75 | "defaultConfiguration": "development" 76 | }, 77 | "extract-i18n": { 78 | "builder": "@angular/build:extract-i18n" 79 | }, 80 | "test": { 81 | "builder": "@angular/build:karma", 82 | "options": { 83 | "polyfills": [ 84 | "zone.js", 85 | "zone.js/testing" 86 | ], 87 | "tsConfig": "tsconfig.spec.json", 88 | "inlineStyleLanguage": "scss", 89 | "assets": [ 90 | "client/assets/favicon.ico", 91 | "client/assets" 92 | ], 93 | "styles": [ 94 | "node_modules/font-awesome/css/font-awesome.min.css", 95 | "client/styles.scss" 96 | ], 97 | "scripts": [ 98 | "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" 99 | ] 100 | } 101 | }, 102 | "lint": { 103 | "builder": "@angular-eslint/builder:lint", 104 | "options": { 105 | "lintFilePatterns": [ 106 | "client/**/*.ts", 107 | "client/**/*.html", 108 | "server/**/*.ts" 109 | ] 110 | } 111 | } 112 | } 113 | } 114 | }, 115 | "cli": { 116 | "schematicCollections": [ 117 | "angular-eslint" 118 | ] 119 | }, 120 | "schematics": { 121 | "@schematics/angular:component": { 122 | "type": "component" 123 | }, 124 | "@schematics/angular:directive": { 125 | "type": "directive" 126 | }, 127 | "@schematics/angular:service": { 128 | "type": "service" 129 | }, 130 | "@schematics/angular:guard": { 131 | "typeSeparator": "." 132 | }, 133 | "@schematics/angular:interceptor": { 134 | "typeSeparator": "." 135 | }, 136 | "@schematics/angular:module": { 137 | "typeSeparator": "." 138 | }, 139 | "@schematics/angular:pipe": { 140 | "typeSeparator": "." 141 | }, 142 | "@schematics/angular:resolver": { 143 | "typeSeparator": "." 144 | } 145 | } 146 | } 147 | --------------------------------------------------------------------------------