├── 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 |
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 |
[0]
21 | }})
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 |
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 |
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 |
--------------------------------------------------------------------------------