diff --git a/apps/angular/66-functional-auth-guard/.eslintrc.json b/apps/angular/66-functional-auth-guard/.eslintrc.json new file mode 100644 index 000000000..8ebcbfd59 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/.eslintrc.json @@ -0,0 +1,36 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "app", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "app", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/apps/angular/66-functional-auth-guard/README.md b/apps/angular/66-functional-auth-guard/README.md new file mode 100644 index 000000000..247e3e054 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/README.md @@ -0,0 +1,13 @@ +# functional-auth-guard + +> author: thomas-laforge + +### Run Application + +```bash +npx nx serve angular-functional-auth-guard +``` + +### Documentation and Instruction + +Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/angular/66-functional-auth-guard/). diff --git a/apps/angular/66-functional-auth-guard/project.json b/apps/angular/66-functional-auth-guard/project.json new file mode 100644 index 000000000..d4c024a74 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/project.json @@ -0,0 +1,87 @@ +{ + "name": "angular-functional-auth-guard", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "prefix": "app", + "sourceRoot": "apps/angular/66-functional-auth-guard/src", + "tags": [], + "targets": { + "build": { + "executor": "@angular/build:application", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/angular/66-functional-auth-guard", + "browser": "apps/angular/66-functional-auth-guard/src/main.ts", + "tsConfig": "apps/angular/66-functional-auth-guard/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "apps/angular/66-functional-auth-guard/public" + } + ], + "styles": ["apps/angular/66-functional-auth-guard/src/styles.scss"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "1mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kb", + "maximumError": "8kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "continuous": true, + "executor": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-functional-auth-guard:build:production" + }, + "development": { + "buildTarget": "angular-functional-auth-guard:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "executor": "@angular/build:extract-i18n", + "options": { + "buildTarget": "angular-functional-auth-guard:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@angular/build:unit-test", + "options": { + "runnerConfig": "apps/angular/66-functional-auth-guard/vitest-base.config.ts" + } + }, + "serve-static": { + "continuous": true, + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "angular-functional-auth-guard:build", + "staticFilePath": "dist/apps/angular/66-functional-auth-guard/browser", + "spa": true + } + } + } +} diff --git a/apps/angular/66-functional-auth-guard/public/favicon.ico b/apps/angular/66-functional-auth-guard/public/favicon.ico new file mode 100644 index 000000000..317ebcb23 Binary files /dev/null and b/apps/angular/66-functional-auth-guard/public/favicon.ico differ diff --git a/apps/angular/66-functional-auth-guard/src/app/admin.component.ts b/apps/angular/66-functional-auth-guard/src/app/admin.component.ts new file mode 100644 index 000000000..e44150aea --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/admin.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-admin', + template: ` +
+

Admin

+ Back to Home +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], +}) +export class AdminComponent {} diff --git a/apps/angular/66-functional-auth-guard/src/app/admin.guard.ts b/apps/angular/66-functional-auth-guard/src/app/admin.guard.ts new file mode 100644 index 000000000..6bbfbd2d1 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/admin.guard.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const adminGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + return authService.isAdmin() || router.createUrlTree(['/']); +}; diff --git a/apps/angular/66-functional-auth-guard/src/app/app.component.spec.ts b/apps/angular/66-functional-auth-guard/src/app/app.component.spec.ts new file mode 100644 index 000000000..4b8632966 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/app.component.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { AppComponent } from './app.component'; +import { AuthService } from './auth.service'; +import { routes } from './app.routes'; + +describe('Functional Auth Guards', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter(routes)], + }); + TestBed.createComponent(AppComponent); + }); + + describe('authGuard', () => { + it('should redirect to / when not logged in', async () => { + const router = TestBed.inject(Router); + await router.navigate(['/dashboard']); + expect(router.url).toBe('/'); + }); + + it('should allow navigation when logged in', async () => { + const authService = TestBed.inject(AuthService); + const router = TestBed.inject(Router); + authService.login('user'); + await router.navigate(['/dashboard']); + expect(router.url).toBe('/dashboard'); + }); + }); + + describe('adminGuard', () => { + it('should redirect to / when not logged in', async () => { + const router = TestBed.inject(Router); + await router.navigate(['/admin']); + expect(router.url).toBe('/'); + }); + + it('should redirect to / when logged in as user', async () => { + const authService = TestBed.inject(AuthService); + const router = TestBed.inject(Router); + authService.login('user'); + await router.navigate(['/admin']); + expect(router.url).toBe('/'); + }); + + it('should allow navigation when logged in as admin', async () => { + const authService = TestBed.inject(AuthService); + const router = TestBed.inject(Router); + authService.login('admin'); + await router.navigate(['/admin']); + expect(router.url).toBe('/admin'); + }); + }); +}); diff --git a/apps/angular/66-functional-auth-guard/src/app/app.component.ts b/apps/angular/66-functional-auth-guard/src/app/app.component.ts new file mode 100644 index 000000000..6f48a095f --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/app.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet], +}) +export class AppComponent {} diff --git a/apps/angular/66-functional-auth-guard/src/app/app.config.ts b/apps/angular/66-functional-auth-guard/src/app/app.config.ts new file mode 100644 index 000000000..414eeaeb8 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/app.config.ts @@ -0,0 +1,16 @@ +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + ], +}; diff --git a/apps/angular/66-functional-auth-guard/src/app/app.routes.ts b/apps/angular/66-functional-auth-guard/src/app/app.routes.ts new file mode 100644 index 000000000..0a43617e9 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/app.routes.ts @@ -0,0 +1,12 @@ +import { Routes } from '@angular/router'; +import { adminGuard } from './admin.guard'; +import { AdminComponent } from './admin.component'; +import { authGuard } from './auth.guard'; +import { DashboardComponent } from './dashboard.component'; +import { HomeComponent } from './home.component'; + +export const routes: Routes = [ + { path: '', component: HomeComponent }, + { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] }, + { path: 'admin', component: AdminComponent, canActivate: [authGuard, adminGuard] }, +]; diff --git a/apps/angular/66-functional-auth-guard/src/app/auth.guard.ts b/apps/angular/66-functional-auth-guard/src/app/auth.guard.ts new file mode 100644 index 000000000..abb444031 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/auth.guard.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const authGuard: CanActivateFn = () => { + const authService = inject(AuthService); + const router = inject(Router); + + return authService.isLoggedIn() || router.createUrlTree(['/']); +}; diff --git a/apps/angular/66-functional-auth-guard/src/app/auth.service.ts b/apps/angular/66-functional-auth-guard/src/app/auth.service.ts new file mode 100644 index 000000000..012ffb44c --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/auth.service.ts @@ -0,0 +1,21 @@ +import { computed, Injectable, signal } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private readonly _isLoggedIn = signal(false); + private readonly _role = signal<'user' | 'admin'>('user'); + + readonly isLoggedIn = this._isLoggedIn.asReadonly(); + readonly role = this._role.asReadonly(); + readonly isAdmin = computed(() => this._isLoggedIn() && this._role() === 'admin'); + + login(role: 'user' | 'admin'): void { + this._isLoggedIn.set(true); + this._role.set(role); + } + + logout(): void { + this._isLoggedIn.set(false); + this._role.set('user'); + } +} diff --git a/apps/angular/66-functional-auth-guard/src/app/dashboard.component.ts b/apps/angular/66-functional-auth-guard/src/app/dashboard.component.ts new file mode 100644 index 000000000..bf5db8a90 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/dashboard.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + template: ` +
+

Dashboard

+ Back to Home +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], +}) +export class DashboardComponent {} diff --git a/apps/angular/66-functional-auth-guard/src/app/home.component.ts b/apps/angular/66-functional-auth-guard/src/app/home.component.ts new file mode 100644 index 000000000..5b56ed2f4 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/app/home.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { AuthService } from './auth.service'; + +@Component({ + selector: 'app-home', + template: ` +
+

Home

+ +

+ Status: + + {{ authService.isLoggedIn() ? 'Logged in as ' + authService.role() : 'Not logged in' }} + +

+ +
+ + + +
+ + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink], +}) +export class HomeComponent { + protected authService = inject(AuthService); +} diff --git a/apps/angular/66-functional-auth-guard/src/index.html b/apps/angular/66-functional-auth-guard/src/index.html new file mode 100644 index 000000000..592c9264b --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/index.html @@ -0,0 +1,13 @@ + + + + + angular-functional-auth-guard + + + + + + + + diff --git a/apps/angular/66-functional-auth-guard/src/main.ts b/apps/angular/66-functional-auth-guard/src/main.ts new file mode 100644 index 000000000..7205a13df --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); diff --git a/apps/angular/66-functional-auth-guard/src/styles.scss b/apps/angular/66-functional-auth-guard/src/styles.scss new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/styles.scss @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/angular/66-functional-auth-guard/src/test-setup/no-teardown.ts b/apps/angular/66-functional-auth-guard/src/test-setup/no-teardown.ts new file mode 100644 index 000000000..655736368 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/src/test-setup/no-teardown.ts @@ -0,0 +1,25 @@ +import { getTestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +import { beforeEach } from 'vitest'; + +/** + * @see https://github.com/angular/angular-cli/issues/31733 + */ +const symbol = Symbol.for('@angular-challenge/testbed-setup'); +const g = globalThis as unknown as { [symbol]: boolean }; + +if (!g[symbol]) { + g[symbol] = true; + + getTestBed().resetTestEnvironment(); + getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), + { teardown: { destroyAfterEach: false } }, + ); +} + +beforeEach(() => getTestBed().resetTestingModule()); diff --git a/apps/angular/66-functional-auth-guard/tailwind.config.js b/apps/angular/66-functional-auth-guard/tailwind.config.js new file mode 100644 index 000000000..38183db2c --- /dev/null +++ b/apps/angular/66-functional-auth-guard/tailwind.config.js @@ -0,0 +1,14 @@ +const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind'); +const { join } = require('path'); + +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'), + ...createGlobPatternsForDependencies(__dirname), + ], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/apps/angular/66-functional-auth-guard/tsconfig.app.json b/apps/angular/66-functional-auth-guard/tsconfig.app.json new file mode 100644 index 000000000..5d5af52c7 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", + "src/test-setup.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts" + ] +} diff --git a/apps/angular/66-functional-auth-guard/tsconfig.json b/apps/angular/66-functional-auth-guard/tsconfig.json new file mode 100644 index 000000000..1ae47abeb --- /dev/null +++ b/apps/angular/66-functional-auth-guard/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "target": "es2022", + "moduleResolution": "bundler", + "isolatedModules": true, + "emitDecoratorMetadata": false, + "module": "preserve", + "lib": ["dom", "es2022"] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/angular/66-functional-auth-guard/tsconfig.spec.json b/apps/angular/66-functional-auth-guard/tsconfig.spec.json new file mode 100644 index 000000000..55718726e --- /dev/null +++ b/apps/angular/66-functional-auth-guard/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/apps/angular/66-functional-auth-guard/vitest-base.config.ts b/apps/angular/66-functional-auth-guard/vitest-base.config.ts new file mode 100644 index 000000000..d5747f9a9 --- /dev/null +++ b/apps/angular/66-functional-auth-guard/vitest-base.config.ts @@ -0,0 +1,20 @@ +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: [ + 'apps/angular/66-functional-auth-guard/src/test-setup/no-teardown.ts', + ], + testTimeout: 3_000, + browser: { + enabled: true, + provider: playwright({ + launchOptions: { + args: ['--remote-debugging-port=9222'], + }, + }), + instances: [{ browser: 'chromium' }], + }, + }, +}); diff --git a/challenge-number.json b/challenge-number.json index c828ee7e3..f8ba4a130 100644 --- a/challenge-number.json +++ b/challenge-number.json @@ -1,6 +1,6 @@ { - "total": 62, - "🟢": 25, + "total": 63, + "🟢": 26, "🟠": 124, "🔴": 212 }