├── src ├── assets │ └── .gitkeep ├── app │ ├── auth │ │ ├── login │ │ │ ├── login.component.spec.ts │ │ │ ├── data-access │ │ │ │ ├── login.service.spec.ts │ │ │ │ └── login.service.ts │ │ │ ├── ui │ │ │ │ ├── login-form.component.spec.ts │ │ │ │ └── login-form.component.ts │ │ │ └── login.component.ts │ │ ├── register │ │ │ ├── register.component.spec.ts │ │ │ ├── utils │ │ │ │ ├── password-matches.spec.ts │ │ │ │ └── password-matches.ts │ │ │ ├── data-access │ │ │ │ ├── register.service.spec.ts │ │ │ │ └── register.service.ts │ │ │ ├── ui │ │ │ │ ├── register-form.component.spec.ts │ │ │ │ └── register-form.component.ts │ │ │ └── register.component.ts │ │ └── auth.routes.ts │ ├── shared │ │ ├── data-access │ │ │ ├── auth.service.spec.ts │ │ │ ├── auth.service.ts │ │ │ ├── message.service.spec.ts │ │ │ └── message.service.ts │ │ ├── interfaces │ │ │ ├── credentials.ts │ │ │ └── message.ts │ │ └── guards │ │ │ └── auth.guard.ts │ ├── app.component.ts │ ├── app.component.spec.ts │ ├── app.routes.ts │ ├── home │ │ ├── ui │ │ │ ├── message-list.component.spec.ts │ │ │ ├── message-input.component.ts │ │ │ ├── message-input.component.spec.ts │ │ │ └── message-list.component.ts │ │ ├── home.component.ts │ │ └── home.component.spec.ts │ └── app.config.ts ├── favicon.ico ├── main.ts ├── environments │ ├── environment.ts │ └── environment.development.ts ├── index.html └── styles.scss ├── firestore.indexes.json ├── .firebaserc ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── firebase.json ├── firestore.rules ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/register/register.component.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/shared/data-access/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/login/data-access/login.service.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/login/ui/login-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/register/utils/password-matches.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/register/data-access/register.service.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/auth/register/ui/register-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "angularstart-chat" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshuamorony/angularstart-chat/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/app/shared/interfaces/credentials.ts: -------------------------------------------------------------------------------- 1 | export interface Credentials { 2 | email: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/app/shared/interfaces/message.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | author: string; 3 | content: string; 4 | created: string; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | standalone: true, 7 | imports: [RouterOutlet], 8 | template: ` `, 9 | styles: [], 10 | }) 11 | export class AppComponent {} 12 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jest" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "emulators": { 7 | "auth": { 8 | "port": 9099 9 | }, 10 | "firestore": { 11 | "port": 8080 12 | }, 13 | "ui": { 14 | "enabled": true 15 | }, 16 | "singleProjectMode": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/auth/auth.routes.ts: -------------------------------------------------------------------------------- 1 | import { Route } from '@angular/router'; 2 | 3 | export const AUTH_ROUTES: Route[] = [ 4 | { path: 'login', loadComponent: () => import('./login/login.component') }, 5 | { 6 | path: 'register', 7 | loadComponent: () => import('./register/register.component'), 8 | }, 9 | { 10 | path: '', 11 | redirectTo: 'login', 12 | pathMatch: 'full', 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | firebase: { 3 | projectId: 'angularstart-chat', 4 | appId: '*******************', 5 | storageBucket: 'angularstart-chat.appspot.com', 6 | apiKey: '***************', 7 | authDomain: 'angularstart-chat.firebaseapp.com', 8 | messagingSenderId: '767794269558', 9 | }, 10 | production: true, 11 | useEmulators: false, 12 | }; 13 | -------------------------------------------------------------------------------- /src/environments/environment.development.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | firebase: { 3 | projectId: 'demo-project', 4 | appId: '*************', 5 | storageBucket: 'angularstart-chat.appspot.com', 6 | apiKey: '*******************', 7 | authDomain: 'angularstart-chat.firebaseapp.com', 8 | messagingSenderId: '767794269558', 9 | }, 10 | production: false, 11 | useEmulators: true, 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(() => 6 | TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }) 9 | ); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/auth/register/utils/password-matches.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; 2 | 3 | export const passwordMatchesValidator: ValidatorFn = ( 4 | control: AbstractControl 5 | ): ValidationErrors | null => { 6 | const password = control.get('password')?.value; 7 | const confirmPassword = control.get('confirmPassword')?.value; 8 | 9 | return password && confirmPassword && password === confirmPassword 10 | ? null 11 | : { passwordMatch: true }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/shared/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { CanActivateFn, Router } from '@angular/router'; 3 | import { AuthService } from '../data-access/auth.service'; 4 | 5 | export const isAuthenticatedGuard = (): CanActivateFn => { 6 | return () => { 7 | const authService = inject(AuthService); 8 | const router = inject(Router); 9 | 10 | if (authService.user()) { 11 | return true; 12 | } 13 | 14 | return router.parseUrl('auth/login'); 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { isAuthenticatedGuard } from './shared/guards/auth.guard'; 3 | 4 | export const routes: Routes = [ 5 | { 6 | path: 'auth', 7 | loadChildren: () => import('./auth/auth.routes').then((m) => m.AUTH_ROUTES), 8 | }, 9 | { 10 | path: 'home', 11 | canActivate: [isAuthenticatedGuard()], 12 | loadComponent: () => import('./home/home.component'), 13 | }, 14 | { 15 | path: '', 16 | redirectTo: 'auth', 17 | pathMatch: 'full', 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularstartChat 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | 5 | match /messages/{message} { 6 | allow read: if isAuthenticated(); 7 | allow create: if isValidMessage(); 8 | allow update, delete: if false 9 | } 10 | 11 | match /{document=**}{ 12 | allow read, write: if false 13 | } 14 | 15 | } 16 | 17 | function isAuthenticated(){ 18 | return request.auth != null; 19 | } 20 | 21 | function isValidMessage(){ 22 | // email of incoming doc should match the authenticated user 23 | return request.resource.data.author == request.auth.token.email; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/app/auth/login/data-access/login.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, resource } from '@angular/core'; 2 | import { toSignal } from '@angular/core/rxjs-interop'; 3 | import { Subject } from 'rxjs'; 4 | import { AuthService } from '../../../shared/data-access/auth.service'; 5 | import { Credentials } from '../../../shared/interfaces/credentials'; 6 | 7 | @Injectable() 8 | export class LoginService { 9 | private authService = inject(AuthService); 10 | 11 | // sources 12 | login$ = new Subject(); 13 | login = toSignal(this.login$); 14 | 15 | userAuthenticated = resource({ 16 | params: this.login, 17 | loader: ({ params }) => this.authService.login(params), 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/auth/register/data-access/register.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject, resource } from '@angular/core'; 2 | import { toSignal } from '@angular/core/rxjs-interop'; 3 | import { Subject } from 'rxjs'; 4 | import { AuthService } from '../../../shared/data-access/auth.service'; 5 | import { Credentials } from '../../../shared/interfaces/credentials'; 6 | 7 | @Injectable() 8 | export class RegisterService { 9 | private authService = inject(AuthService); 10 | 11 | // sources 12 | createUser$ = new Subject(); 13 | createUser = toSignal(this.createUser$); 14 | 15 | createdUser = resource({ 16 | params: this.createUser, 17 | loader: ({ params }) => this.authService.createAccount(params), 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-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 | # Firebase 45 | .firebase 46 | *-debug.log 47 | .runtimeconfig.json 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "bundler", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "useDefineForClassFields": false, 22 | "lib": ["ES2022", "dom"] 23 | }, 24 | "angularCompilerOptions": { 25 | "enableI18nLegacyMessageIdFormat": false, 26 | "strictInjectionParameters": true, 27 | "strictInputAccessModifiers": true, 28 | "strictTemplates": true, 29 | "_enabledBlockTypes": ["if", "for", "switch", "defer"] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/app/auth/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, inject } from '@angular/core'; 2 | import { RegisterFormComponent } from './ui/register-form.component'; 3 | import { RegisterService } from './data-access/register.service'; 4 | import { Router } from '@angular/router'; 5 | import { AuthService } from 'src/app/shared/data-access/auth.service'; 6 | 7 | @Component({ 8 | standalone: true, 9 | selector: 'app-register', 10 | template: ` 11 |
12 | 16 |
17 | `, 18 | providers: [RegisterService], 19 | imports: [RegisterFormComponent], 20 | }) 21 | export default class RegisterComponent { 22 | public registerService = inject(RegisterService); 23 | private authService = inject(AuthService); 24 | private router = inject(Router); 25 | 26 | constructor() { 27 | effect(() => { 28 | if (this.authService.user()) { 29 | this.router.navigate(['home']); 30 | } 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AngularstartChat 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 16.2.2. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /src/app/shared/data-access/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, inject } from '@angular/core'; 2 | import { 3 | User, 4 | createUserWithEmailAndPassword, 5 | signInWithEmailAndPassword, 6 | signOut, 7 | } from 'firebase/auth'; 8 | import { authState } from 'rxfire/auth'; 9 | import { Credentials } from '../interfaces/credentials'; 10 | import { AUTH } from '../../app.config'; 11 | import { toSignal } from '@angular/core/rxjs-interop'; 12 | 13 | export type AuthUser = User | null | undefined; 14 | 15 | @Injectable({ 16 | providedIn: 'root', 17 | }) 18 | export class AuthService { 19 | private auth = inject(AUTH); 20 | 21 | // sources 22 | private authState$ = authState(this.auth); 23 | 24 | // state 25 | user = toSignal(this.authState$); 26 | 27 | async login(credentials: Credentials | undefined) { 28 | if (!credentials) return null; 29 | 30 | return signInWithEmailAndPassword( 31 | this.auth, 32 | credentials.email, 33 | credentials.password, 34 | ); 35 | } 36 | 37 | logout() { 38 | signOut(this.auth); 39 | } 40 | 41 | async createAccount(credentials: Credentials | undefined) { 42 | if (!credentials) return null; 43 | 44 | return createUserWithEmailAndPassword( 45 | this.auth, 46 | credentials.email, 47 | credentials.password, 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/home/ui/message-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MessageListComponent } from './message-list.component'; 3 | import { By } from '@angular/platform-browser'; 4 | 5 | describe('MessageListComponent', () => { 6 | let component: MessageListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [MessageListComponent], 12 | }) 13 | .overrideComponent(MessageListComponent, { 14 | remove: { imports: [] }, 15 | add: { imports: [] }, 16 | }) 17 | .compileComponents(); 18 | 19 | fixture = TestBed.createComponent(MessageListComponent); 20 | component = fixture.componentInstance; 21 | component.messages = []; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | 29 | describe('input: messages', () => { 30 | it('should render an item for each message', () => { 31 | const testMessages = [{}, {}, {}] as any; 32 | component.messages = testMessages; 33 | 34 | fixture.detectChanges(); 35 | 36 | const messages = fixture.debugElement.queryAll( 37 | By.css('[data-testid="message"]') 38 | ); 39 | 40 | expect(messages.length).toEqual(testMessages.length); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/app/home/ui/message-input.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, output } from '@angular/core'; 2 | import { FormControl, ReactiveFormsModule } from '@angular/forms'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatIconModule } from '@angular/material/icon'; 5 | 6 | @Component({ 7 | standalone: true, 8 | selector: 'app-message-input', 9 | template: ` 10 | 15 | 21 | `, 22 | imports: [ReactiveFormsModule, MatButtonModule, MatIconModule], 23 | styles: [ 24 | ` 25 | :host { 26 | width: 100%; 27 | position: relative; 28 | } 29 | 30 | input { 31 | width: 100%; 32 | background: var(--white); 33 | border: none; 34 | font-size: 1.2em; 35 | padding: 2rem 1rem; 36 | } 37 | 38 | button { 39 | height: 100% !important; 40 | position: absolute; 41 | right: 0; 42 | bottom: 0; 43 | 44 | mat-icon { 45 | margin-right: 0; 46 | } 47 | } 48 | `, 49 | ], 50 | }) 51 | export class MessageInputComponent { 52 | send = output(); 53 | 54 | messageControl = new FormControl(); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/shared/data-access/message.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { MessageService } from './message.service'; 3 | 4 | xdescribe('MessageService', () => { 5 | let service: MessageService; 6 | 7 | const mockCollectionReference = jest.fn(); 8 | const mockCollection = jest 9 | .fn() 10 | .mockReturnValue(mockCollectionReference as any); 11 | const mockAddDoc = jest.fn(); 12 | 13 | const testUser = { 14 | email: '', 15 | }; 16 | 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | providers: [], 20 | }); 21 | 22 | service = TestBed.inject(MessageService); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(service).toBeTruthy(); 27 | }); 28 | 29 | describe('source: add$', () => { 30 | beforeEach(() => { 31 | Date.now = jest.fn(() => 1); 32 | }); 33 | 34 | xit('should create a new document in the messages collection using the supplied message and authenticated user as author', async () => { 35 | const testMessage = { 36 | author: '', 37 | content: 'test', 38 | }; 39 | 40 | service.add$.next(testMessage.content); 41 | 42 | expect(mockCollection).toHaveBeenCalledWith({} as any, 'messages'); 43 | expect(mockAddDoc).toHaveBeenCalledWith(mockCollectionReference as any, { 44 | ...testMessage, 45 | created: Date.now().toString(), 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, InjectionToken } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | import { initializeApp } from 'firebase/app'; 4 | import { 5 | Firestore, 6 | initializeFirestore, 7 | connectFirestoreEmulator, 8 | getFirestore, 9 | } from 'firebase/firestore'; 10 | import { getAuth, connectAuthEmulator } from 'firebase/auth'; 11 | import { environment } from '../environments/environment'; 12 | 13 | import { routes } from './app.routes'; 14 | import { provideAnimations } from '@angular/platform-browser/animations'; 15 | 16 | const app = initializeApp(environment.firebase); 17 | 18 | export const AUTH = new InjectionToken('Firebase auth', { 19 | providedIn: 'root', 20 | factory: () => { 21 | const auth = getAuth(); 22 | if (environment.useEmulators) { 23 | connectAuthEmulator(auth, 'http://localhost:9099', { 24 | disableWarnings: true, 25 | }); 26 | } 27 | return auth; 28 | }, 29 | }); 30 | 31 | export const FIRESTORE = new InjectionToken('Firebase firestore', { 32 | providedIn: 'root', 33 | factory: () => { 34 | let firestore: Firestore; 35 | if (environment.useEmulators) { 36 | firestore = initializeFirestore(app, {}); 37 | connectFirestoreEmulator(firestore, 'localhost', 8080); 38 | } else { 39 | firestore = getFirestore(); 40 | } 41 | return firestore; 42 | }, 43 | }); 44 | 45 | export const appConfig: ApplicationConfig = { 46 | providers: [provideRouter(routes), provideAnimations()], 47 | }; 48 | -------------------------------------------------------------------------------- /src/app/auth/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, inject } from '@angular/core'; 2 | import { Router, RouterModule } from '@angular/router'; 3 | import { LoginFormComponent } from './ui/login-form.component'; 4 | import { LoginService } from './data-access/login.service'; 5 | import { AuthService } from '../../shared/data-access/auth.service'; 6 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 7 | 8 | @Component({ 9 | standalone: true, 10 | selector: 'app-login', 11 | template: ` 12 |
13 | @if (authService.user() === null) { 14 | 18 | Create account 19 | } @else { 20 | 21 | } 22 |
23 | `, 24 | providers: [LoginService], 25 | imports: [RouterModule, LoginFormComponent, MatProgressSpinnerModule], 26 | styles: [ 27 | ` 28 | a { 29 | margin: 2rem; 30 | color: var(--accent-darker-color); 31 | } 32 | `, 33 | ], 34 | }) 35 | export default class LoginComponent { 36 | public loginService = inject(LoginService); 37 | public authService = inject(AuthService); 38 | private router = inject(Router); 39 | 40 | constructor() { 41 | effect(() => { 42 | if (this.authService.user()) { 43 | this.router.navigate(['home']); 44 | } 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularstart-chat", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "firebase emulators:exec --project=demo-project --ui 'ng serve'", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^20.0.0-rc.1", 14 | "@angular/cdk": "^20.0.0-rc.1", 15 | "@angular/common": "^20.0.0-rc.1", 16 | "@angular/compiler": "^20.0.0-rc.1", 17 | "@angular/core": "^20.0.0-rc.1", 18 | "@angular/forms": "^20.0.0-rc.1", 19 | "@angular/material": "^20.0.0-rc.1", 20 | "@angular/platform-browser": "^20.0.0-rc.1", 21 | "@angular/platform-browser-dynamic": "^20.0.0-rc.1", 22 | "@angular/router": "^20.0.0-rc.1", 23 | "firebase": "^10.7.1", 24 | "rxfire": "^6.1.0", 25 | "rxjs": "~7.8.0", 26 | "tslib": "^2.3.0", 27 | "zone.js": "~0.15.0" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "^20.0.0-rc.2", 31 | "@angular/cli": "^20.0.0-rc.2", 32 | "@angular/compiler-cli": "^20.0.0-rc.1", 33 | "@hirez_io/observer-spy": "^2.2.0", 34 | "@types/jasmine": "~4.3.0", 35 | "@types/jest": "^29.5.5", 36 | "cross-env": "^7.0.3", 37 | "jasmine-core": "~4.6.0", 38 | "jest": "^29.7.0", 39 | "jest-environment-jsdom": "^29.7.0", 40 | "karma": "~6.4.0", 41 | "karma-chrome-launcher": "~3.2.0", 42 | "karma-coverage": "~2.2.0", 43 | "karma-jasmine": "~5.1.0", 44 | "karma-jasmine-html-reporter": "~2.1.0", 45 | "typescript": "~5.8.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/home/ui/message-input.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { MessageInputComponent } from './message-input.component'; 3 | import { By } from '@angular/platform-browser'; 4 | import { subscribeSpyTo } from '@hirez_io/observer-spy'; 5 | 6 | describe('MessageInputComponent', () => { 7 | let component: MessageInputComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | imports: [MessageInputComponent], 13 | }) 14 | .overrideComponent(MessageInputComponent, { 15 | remove: { imports: [] }, 16 | add: { imports: [] }, 17 | }) 18 | .compileComponents(); 19 | 20 | fixture = TestBed.createComponent(MessageInputComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('should create', () => { 26 | expect(component).toBeTruthy(); 27 | }); 28 | 29 | describe('output: send', () => { 30 | it('should emit form control value when submit button clicked', () => { 31 | const observerSpy = subscribeSpyTo(component.send); 32 | 33 | const testValue = 'hello'; 34 | component.messageControl.setValue(testValue); 35 | 36 | const submit = fixture.debugElement.query(By.css('button')); 37 | submit.nativeElement.click(); 38 | 39 | expect(observerSpy.getLastValue()).toEqual(testValue); 40 | }); 41 | 42 | it('should clear form control when submitted', () => { 43 | component.messageControl.setValue('hello'); 44 | 45 | const submit = fixture.debugElement.query(By.css('button')); 46 | submit.nativeElement.click(); 47 | 48 | expect(component.messageControl.value).toEqual(null); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, inject } from '@angular/core'; 2 | import { MatToolbarModule } from '@angular/material/toolbar'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatIconModule } from '@angular/material/icon'; 5 | import { MessageInputComponent } from './ui/message-input.component'; 6 | import { MessageService } from '../shared/data-access/message.service'; 7 | import { MessageListComponent } from './ui/message-list.component'; 8 | import { AuthService } from '../shared/data-access/auth.service'; 9 | import { Router } from '@angular/router'; 10 | 11 | @Component({ 12 | standalone: true, 13 | selector: 'app-home', 14 | template: ` 15 |
16 | 17 | 18 | 21 | 22 | 26 | 27 |
28 | `, 29 | imports: [ 30 | MessageInputComponent, 31 | MessageListComponent, 32 | MatToolbarModule, 33 | MatIconModule, 34 | MatButtonModule, 35 | ], 36 | styles: [ 37 | ` 38 | .container { 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: space-between; 42 | height: 100%; 43 | } 44 | 45 | mat-toolbar { 46 | box-shadow: 0px -7px 11px 0px var(--accent-color); 47 | } 48 | 49 | app-message-list { 50 | height: 100%; 51 | width: 100%; 52 | } 53 | 54 | app-message-input { 55 | position: fixed; 56 | bottom: 0; 57 | } 58 | `, 59 | ], 60 | }) 61 | export default class HomeComponent { 62 | messageService = inject(MessageService); 63 | authService = inject(AuthService); 64 | private router = inject(Router); 65 | 66 | constructor() { 67 | effect(() => { 68 | if (!this.authService.user()) { 69 | this.router.navigate(['auth', 'login']); 70 | } 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import HomeComponent from './home.component'; 3 | import { DebugElement } from '@angular/core'; 4 | import { By } from '@angular/platform-browser'; 5 | import { MessageService } from '../shared/data-access/message.service'; 6 | 7 | describe('HomeComponent', () => { 8 | let component: HomeComponent; 9 | let fixture: ComponentFixture; 10 | let messageService: MessageService; 11 | 12 | const mockMessages = [{}, {}]; 13 | 14 | beforeEach(() => { 15 | TestBed.configureTestingModule({ 16 | imports: [HomeComponent], 17 | providers: [ 18 | { 19 | provide: MessageService, 20 | useValue: { 21 | add$: { 22 | next: jest.fn(), 23 | }, 24 | messages: jest.fn().mockReturnValue(mockMessages), 25 | }, 26 | }, 27 | ], 28 | }) 29 | .overrideComponent(HomeComponent, { 30 | remove: { imports: [] }, 31 | add: { imports: [] }, 32 | }) 33 | .compileComponents(); 34 | 35 | fixture = TestBed.createComponent(HomeComponent); 36 | component = fixture.componentInstance; 37 | messageService = TestBed.inject(MessageService); 38 | fixture.detectChanges(); 39 | }); 40 | 41 | it('should create', () => { 42 | expect(component).toBeTruthy(); 43 | }); 44 | 45 | describe('app-message-list', () => { 46 | let messageList: DebugElement; 47 | 48 | beforeEach(() => { 49 | messageList = fixture.debugElement.query(By.css('app-message-list')); 50 | }); 51 | 52 | it('should use messages selector as input', () => { 53 | expect(messageList.componentInstance.messages).toEqual(mockMessages); 54 | }); 55 | }); 56 | 57 | describe('app-message-input', () => { 58 | let messageInput: DebugElement; 59 | 60 | beforeEach(() => { 61 | messageInput = fixture.debugElement.query(By.css('app-message-input')); 62 | }); 63 | 64 | describe('output: send', () => { 65 | it('should next the add$ source on the message service with value', () => { 66 | const testValue = 'hello'; 67 | messageInput.triggerEventHandler('send', testValue); 68 | 69 | expect(messageService.add$.next).toHaveBeenCalledWith(testValue); 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/app/home/ui/message-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, effect, input, viewChild } from '@angular/core'; 2 | import { CdkScrollable, ScrollingModule } from '@angular/cdk/scrolling'; 3 | import { AuthUser } from 'src/app/shared/data-access/auth.service'; 4 | import { Message } from 'src/app/shared/interfaces/message'; 5 | 6 | @Component({ 7 | standalone: true, 8 | selector: 'app-message-list', 9 | template: ` 10 |
    11 | @for (message of messages(); track message.created) { 12 |
  • 17 |
    18 | 23 |
    24 |
    25 | {{ message.author }} 26 |

    27 | {{ message.content }} 28 |

    29 |
    30 |
  • 31 | } 32 |
33 | `, 34 | styles: [ 35 | ` 36 | ul { 37 | height: 100%; 38 | overflow: scroll; 39 | list-style-type: none; 40 | padding: 1rem; 41 | padding-bottom: 5rem; 42 | margin: 0; 43 | } 44 | 45 | li { 46 | display: flex; 47 | margin-bottom: 2rem; 48 | } 49 | 50 | .avatar { 51 | width: 75px; 52 | margin: 0 1rem; 53 | height: auto; 54 | filter: drop-shadow(2px 3px 5px var(--accent-darker-color)); 55 | } 56 | 57 | .message { 58 | width: 100%; 59 | background: var(--white); 60 | padding: 2rem; 61 | border-radius: 5px; 62 | filter: drop-shadow(2px 4px 3px var(--primary-darker-color)); 63 | } 64 | `, 65 | ], 66 | imports: [ScrollingModule], 67 | }) 68 | export class MessageListComponent { 69 | messages = input.required(); 70 | activeUser = input.required(); 71 | 72 | scrollContainer = viewChild.required(CdkScrollable); 73 | 74 | constructor() { 75 | effect(() => { 76 | if (this.messages().length && this.scrollContainer()) { 77 | this.scrollContainer().scrollTo({ 78 | bottom: 0, 79 | behavior: 'smooth', 80 | }); 81 | } 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | // Custom Theming for Angular Material 2 | // For more information: https://material.angular.io/guide/theming 3 | @use "@angular/material" as mat; 4 | 5 | $theme: mat.define-theme( 6 | ( 7 | color: ( 8 | theme-type: light, 9 | primary: mat.$orange-palette, 10 | tertiary: mat.$blue-palette, 11 | ), 12 | ) 13 | ); 14 | 15 | :root { 16 | --primary-color: #{mat.get-theme-color($theme, primary, 70)}; 17 | --primary-lighter-color: #{mat.get-theme-color($theme, primary, 90)}; 18 | --primary-darker-color: #{mat.get-theme-color($theme, primary, 50)}; 19 | --accent-color: #{mat.get-theme-color($theme, primary, 70)}; 20 | --accent-lighter-color: #{mat.get-theme-color($theme, primary, 90)}; 21 | --accent-darker-color: #{mat.get-theme-color($theme, primary, 50)}; 22 | --white: #ecf0f1; 23 | } 24 | 25 | :root { 26 | --primary-color: #{mat.get-theme-color($theme, primary, 70)}; 27 | --primary-lighter-color: #{mat.get-theme-color($theme, primary, 90)}; 28 | --primary-darker-color: #{mat.get-theme-color($theme, primary, 50)}; 29 | --accent-color: #{mat.get-theme-color($theme, primary, 70)}; 30 | --accent-lighter-color: #{mat.get-theme-color($theme, primary, 90)}; 31 | --accent-darker-color: #{mat.get-theme-color($theme, primary, 50)}; 32 | --white: #ecf0f1; 33 | } 34 | 35 | html { 36 | @include mat.all-component-themes($theme); 37 | } 38 | 39 | html, 40 | body { 41 | height: 100%; 42 | } 43 | body { 44 | margin: 0; 45 | font-family: Roboto, "Helvetica Neue", sans-serif; 46 | } 47 | 48 | .container { 49 | height: 100%; 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | justify-content: center; 54 | } 55 | 56 | .gradient-bg { 57 | background: linear-gradient( 58 | 138deg, 59 | var(--primary-darker-color) 0%, 60 | var(--primary-color) 100% 61 | ); 62 | } 63 | 64 | .spacer { 65 | flex: 1 1 auto; 66 | } 67 | 68 | @keyframes animateInPrimary { 69 | 0% { 70 | transform: translate3d(-100%, 0, 0); 71 | } 72 | 73 | 100% { 74 | transform: translate3d(0, 0, 0); 75 | } 76 | } 77 | 78 | @keyframes animateInSecondary { 79 | 0% { 80 | opacity: 0; 81 | } 82 | 83 | 50% { 84 | opacity: 0; 85 | } 86 | 87 | 100% { 88 | opacity: 1; 89 | } 90 | } 91 | 92 | .animate-in-primary { 93 | animation: animateInPrimary; 94 | animation: animateInPrimary; 95 | animation-duration: 750ms; 96 | } 97 | 98 | .animate-in-secondary { 99 | animation: animateInSecondary ease-in 1; 100 | animation: animateInSecondary ease-in 1; 101 | animation-duration: 750ms; 102 | } 103 | -------------------------------------------------------------------------------- /src/app/auth/login/ui/login-form.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | inject, 4 | input, 5 | output, 6 | ResourceStatus, 7 | } from '@angular/core'; 8 | import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; 9 | import { MatButtonModule } from '@angular/material/button'; 10 | import { MatIconModule } from '@angular/material/icon'; 11 | import { MatInputModule } from '@angular/material/input'; 12 | import { MatFormFieldModule } from '@angular/material/form-field'; 13 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 14 | import { Credentials } from '../../../shared/interfaces/credentials'; 15 | 16 | @Component({ 17 | standalone: true, 18 | selector: 'app-login-form', 19 | template: ` 20 |
24 | 25 | email 26 | 32 | mail 33 | 34 | 35 | password 36 | 42 | lock 43 | 44 | 45 | @if (loginStatus() === 'error') { 46 | Could not log you in with those details. 47 | } 48 | @if (loginStatus() === 'loading') { 49 | 50 | } 51 | 52 | 60 |
61 | `, 62 | imports: [ 63 | ReactiveFormsModule, 64 | MatButtonModule, 65 | MatFormFieldModule, 66 | MatInputModule, 67 | MatIconModule, 68 | MatProgressSpinnerModule, 69 | ], 70 | styles: [ 71 | ` 72 | form { 73 | display: flex; 74 | flex-direction: column; 75 | align-items: center; 76 | } 77 | 78 | button { 79 | width: 100%; 80 | } 81 | 82 | mat-error { 83 | margin: 5px 0; 84 | } 85 | 86 | mat-spinner { 87 | margin: 1rem 0; 88 | } 89 | `, 90 | ], 91 | }) 92 | export class LoginFormComponent { 93 | loginStatus = input.required(); 94 | login = output(); 95 | 96 | private fb = inject(FormBuilder); 97 | 98 | loginForm = this.fb.nonNullable.group({ 99 | email: [''], 100 | password: [''], 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /src/app/shared/data-access/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, computed, inject, signal } from '@angular/core'; 2 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 3 | import { Observable, Subject, defer, exhaustMap } from 'rxjs'; 4 | import { collection, query, orderBy, limit, addDoc } from 'firebase/firestore'; 5 | import { collectionData } from 'rxfire/firestore'; 6 | import { filter, map, retry } from 'rxjs/operators'; 7 | 8 | import { FIRESTORE } from '../../app.config'; 9 | import { Message } from '../interfaces/message'; 10 | import { AuthService } from './auth.service'; 11 | 12 | interface MessageState { 13 | messages: Message[]; 14 | error: string | null; 15 | } 16 | 17 | @Injectable({ 18 | providedIn: 'root', 19 | }) 20 | export class MessageService { 21 | private firestore = inject(FIRESTORE); 22 | private authService = inject(AuthService); 23 | private authUser$ = toObservable(this.authService.user); 24 | 25 | // sources 26 | messages$ = this.getMessages().pipe( 27 | // restart stream when user reauthenticates 28 | retry({ 29 | delay: () => this.authUser$.pipe(filter((user) => !!user)), 30 | }), 31 | ); 32 | add$ = new Subject(); 33 | error$ = new Subject(); 34 | logout$ = this.authUser$.pipe(filter((user) => !user)); 35 | 36 | // state 37 | private state = signal({ 38 | messages: [], 39 | error: null, 40 | }); 41 | 42 | // selectors 43 | messages = computed(() => this.state().messages); 44 | error = computed(() => this.state().error); 45 | 46 | constructor() { 47 | // reducers 48 | this.messages$.pipe(takeUntilDestroyed()).subscribe((messages) => 49 | this.state.update((state) => ({ 50 | ...state, 51 | messages, 52 | })), 53 | ); 54 | 55 | this.add$ 56 | .pipe( 57 | takeUntilDestroyed(), 58 | exhaustMap((message) => this.addMessage(message)), 59 | ) 60 | .subscribe({ 61 | error: (err) => { 62 | console.log(err); 63 | this.error$.next('Failed to send message'); 64 | }, 65 | }); 66 | 67 | this.logout$ 68 | .pipe(takeUntilDestroyed()) 69 | .subscribe(() => 70 | this.state.update((state) => ({ ...state, messages: [] })), 71 | ); 72 | 73 | this.error$ 74 | .pipe(takeUntilDestroyed()) 75 | .subscribe((error) => 76 | this.state.update((state) => ({ ...state, error })), 77 | ); 78 | } 79 | 80 | private getMessages() { 81 | const messagesCollection = query( 82 | collection(this.firestore, 'messages'), 83 | orderBy('created', 'desc'), 84 | limit(50), 85 | ); 86 | 87 | return collectionData(messagesCollection, { idField: 'id' }).pipe( 88 | map((messages) => [...messages].reverse()), 89 | ) as Observable; 90 | } 91 | 92 | private addMessage(message: string) { 93 | const newMessage = { 94 | author: this.authService.user()?.email, 95 | content: message, 96 | created: Date.now().toString(), 97 | }; 98 | 99 | const messagesCollection = collection(this.firestore, 'messages'); 100 | return defer(() => addDoc(messagesCollection, newMessage)); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angularstart-chat": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "inlineTemplate": true, 11 | "inlineStyle": true, 12 | "style": "scss", 13 | "standalone": true 14 | }, 15 | "@schematics/angular:directive": { 16 | "standalone": true 17 | }, 18 | "@schematics/angular:pipe": { 19 | "standalone": true 20 | } 21 | }, 22 | "root": "", 23 | "sourceRoot": "src", 24 | "prefix": "app", 25 | "architect": { 26 | "build": { 27 | "builder": "@angular-devkit/build-angular:application", 28 | "options": { 29 | "outputPath": { 30 | "base": "dist/angularstart-chat" 31 | }, 32 | "index": "src/index.html", 33 | "polyfills": [ 34 | "zone.js" 35 | ], 36 | "tsConfig": "tsconfig.app.json", 37 | "inlineStyleLanguage": "scss", 38 | "assets": [ 39 | "src/favicon.ico", 40 | "src/assets" 41 | ], 42 | "styles": [ 43 | "src/styles.scss" 44 | ], 45 | "scripts": [], 46 | "browser": "src/main.ts" 47 | }, 48 | "configurations": { 49 | "production": { 50 | "budgets": [ 51 | { 52 | "type": "initial", 53 | "maximumWarning": "500kb", 54 | "maximumError": "1mb" 55 | }, 56 | { 57 | "type": "anyComponentStyle", 58 | "maximumWarning": "2kb", 59 | "maximumError": "4kb" 60 | } 61 | ], 62 | "outputHashing": "all" 63 | }, 64 | "development": { 65 | "optimization": false, 66 | "extractLicenses": false, 67 | "sourceMap": true, 68 | "namedChunks": true, 69 | "fileReplacements": [ 70 | { 71 | "replace": "src/environments/environment.ts", 72 | "with": "src/environments/environment.development.ts" 73 | } 74 | ] 75 | } 76 | }, 77 | "defaultConfiguration": "production" 78 | }, 79 | "serve": { 80 | "builder": "@angular-devkit/build-angular:dev-server", 81 | "configurations": { 82 | "production": { 83 | "buildTarget": "angularstart-chat:build:production" 84 | }, 85 | "development": { 86 | "buildTarget": "angularstart-chat:build:development" 87 | } 88 | }, 89 | "defaultConfiguration": "development" 90 | }, 91 | "extract-i18n": { 92 | "builder": "@angular-devkit/build-angular:extract-i18n", 93 | "options": { 94 | "buildTarget": "angularstart-chat:build" 95 | } 96 | }, 97 | "test": { 98 | "builder": "@angular-devkit/build-angular:jest", 99 | "options": { 100 | "tsConfig": "tsconfig.spec.json", 101 | "polyfills": [ 102 | "zone.js", 103 | "zone.js/testing" 104 | ], 105 | "include": [ 106 | "src/**/*.spec.ts" 107 | ] 108 | } 109 | } 110 | } 111 | } 112 | }, 113 | "schematics": { 114 | "@schematics/angular:component": { 115 | "type": "component" 116 | }, 117 | "@schematics/angular:directive": { 118 | "type": "directive" 119 | }, 120 | "@schematics/angular:service": { 121 | "type": "service" 122 | }, 123 | "@schematics/angular:guard": { 124 | "typeSeparator": "." 125 | }, 126 | "@schematics/angular:interceptor": { 127 | "typeSeparator": "." 128 | }, 129 | "@schematics/angular:module": { 130 | "typeSeparator": "." 131 | }, 132 | "@schematics/angular:pipe": { 133 | "typeSeparator": "." 134 | }, 135 | "@schematics/angular:resolver": { 136 | "typeSeparator": "." 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/app/auth/register/ui/register-form.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | inject, 4 | input, 5 | output, 6 | ResourceStatus, 7 | } from '@angular/core'; 8 | import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; 9 | import { MatButtonModule } from '@angular/material/button'; 10 | import { MatIconModule } from '@angular/material/icon'; 11 | import { MatInputModule } from '@angular/material/input'; 12 | import { MatFormFieldModule } from '@angular/material/form-field'; 13 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 14 | import { Credentials } from '../../../shared/interfaces/credentials'; 15 | import { passwordMatchesValidator } from '../utils/password-matches'; 16 | 17 | @Component({ 18 | standalone: true, 19 | selector: 'app-register-form', 20 | template: ` 21 |
22 | 23 | email 24 | 30 | email 31 | @if ( 32 | (registerForm.controls.email.dirty || form.submitted) && 33 | !registerForm.controls.email.valid 34 | ) { 35 | Please provide a valid email 36 | } 37 | 38 | 39 | password 40 | 47 | lock 48 | @if ( 49 | (registerForm.controls.password.dirty || form.submitted) && 50 | !registerForm.controls.password.valid 51 | ) { 52 | Password must be at least 8 characters long 53 | } 54 | 55 | 56 | confirm password 57 | 63 | lock 64 | @if ( 65 | (registerForm.controls.confirmPassword.dirty || form.submitted) && 66 | registerForm.hasError('passwordMatch') 67 | ) { 68 | Must match password field 69 | } 70 | 71 | 72 | @if (status() === 'error') { 73 | Could not create account with those details. 74 | } @else if (status() === 'loading') { 75 | 76 | } 77 | 78 | 86 |
87 | `, 88 | imports: [ 89 | ReactiveFormsModule, 90 | MatButtonModule, 91 | MatFormFieldModule, 92 | MatInputModule, 93 | MatIconModule, 94 | MatProgressSpinnerModule, 95 | ], 96 | styles: [ 97 | ` 98 | form { 99 | display: flex; 100 | flex-direction: column; 101 | align-items: center; 102 | } 103 | 104 | button { 105 | width: 100%; 106 | } 107 | 108 | mat-error { 109 | margin: 5px 0; 110 | } 111 | 112 | mat-spinner { 113 | margin: 1rem 0; 114 | } 115 | `, 116 | ], 117 | }) 118 | export class RegisterFormComponent { 119 | status = input.required(); 120 | register = output(); 121 | 122 | private fb = inject(FormBuilder); 123 | 124 | registerForm = this.fb.nonNullable.group( 125 | { 126 | email: ['', [Validators.email, Validators.required]], 127 | password: ['', [Validators.minLength(8), Validators.required]], 128 | confirmPassword: ['', [Validators.required]], 129 | }, 130 | { 131 | updateOn: 'blur', 132 | validators: [passwordMatchesValidator], 133 | }, 134 | ); 135 | 136 | onSubmit() { 137 | if (this.registerForm.valid) { 138 | const { confirmPassword, ...credentials } = 139 | this.registerForm.getRawValue(); 140 | this.register.emit(credentials); 141 | } 142 | } 143 | } 144 | --------------------------------------------------------------------------------