From 64612573b671e88cd1e05acfe087670ae4e518fe Mon Sep 17 00:00:00 2001 From: admclamb Date: Thu, 2 Apr 2026 00:06:25 -0400 Subject: [PATCH 1/3] setup pipeline --- .github/workflows/ci.yml | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95a7c96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +env: + node_version: '24.11.1' + +on: + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + with: + version: 10.27.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.node_version }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build --configuration=staging + env: + NODE_ENV: development + NG_APP_API_SERVER_URL: ${{ vars.STAGING_API_SERVER_URL }} + NG_APP_AUTH_DOMAIN: ${{ vars.STAGING_AUTH_DOMAIN }} + NG_APP_AUTH_CLIENT_ID: ${{ vars.STAGING_AUTH_CLIENT_ID }} + NG_APP_AUTH_AUDIENCE: ${{ vars.STAGING_AUTH_AUDIENCE }} + NG_APP_AUTH_CALLBACK_URL: ${{ vars.STAGING_AUTH_CALLBACK_URL }} + NG_APP_AUTH_CACHE_LOCATION: ${{ vars.STAGING_AUTH_CACHE_LOCATION }} + NG_APP_AUTH_USE_REFRESH_TOKENS: ${{ vars.STAGING_AUTH_USE_REFRESH_TOKENS }} + + - name: Lint code + run: pnpm lint + + - name: Run prettier + run: pnpm prettier --check "**/*.{ts,js,json,css,html}" + + - name: Run tests + run: pnpm test --watch=false --coverage + env: + NODE_ENV: development + NG_APP_API_SERVER_URL: ${{ vars.STAGING_API_SERVER_URL }} + NG_APP_AUTH_DOMAIN: ${{ vars.STAGING_AUTH_DOMAIN }} + NG_APP_AUTH_CLIENT_ID: ${{ vars.STAGING_AUTH_CLIENT_ID }} + NG_APP_AUTH_AUDIENCE: ${{ vars.STAGING_AUTH_AUDIENCE }} + NG_APP_AUTH_CALLBACK_URL: ${{ vars.STAGING_AUTH_CALLBACK_URL }} + NG_APP_AUTH_CACHE_LOCATION: ${{ vars.STAGING_AUTH_CACHE_LOCATION }} + NG_APP_AUTH_USE_REFRESH_TOKENS: ${{ vars.STAGING_AUTH_USE_REFRESH_TOKENS }} + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-pr + path: coverage/ From 8f1b5640d2102b3c53c26aa7021425172d8111a0 Mon Sep 17 00:00:00 2001 From: admclamb Date: Thu, 2 Apr 2026 01:05:31 -0400 Subject: [PATCH 2/3] Setup guards --- .env.example | 1 + src/app/app.routes.ts | 5 ++ src/app/user/permission-guard.spec.ts | 112 ++++++++++++++++++++++++++ src/app/user/permission-guard.ts | 28 +++++++ src/app/user/user-permission.ts | 3 + src/app/user/user.ts | 3 + 6 files changed, 152 insertions(+) create mode 100644 src/app/user/permission-guard.spec.ts create mode 100644 src/app/user/permission-guard.ts create mode 100644 src/app/user/user-permission.ts diff --git a/.env.example b/.env.example index 02ffd17..435e589 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ NG_APP_API_SERVER_URL= +NG_APP_CLIENT_ID= NG_APP_AUTH_DOMAIN= NG_APP_AUTH_CACHE_LOCATION= NG_APP_AUTH_USE_REFRESH_TOKENS= diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 0d61263..5b18f36 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,10 +1,15 @@ import { Routes } from '@angular/router'; import { NotFound } from './not-found/not-found'; import { Home } from './home/home'; +import { AuthGuard } from '@auth0/auth0-angular'; +import { userGuard } from './user/user-guard'; +import { permissionGuard } from './user/permission-guard'; +import { UserPermission } from './user/user-permission'; export const routes: Routes = [ { path: '', + canActivate: [AuthGuard, userGuard, permissionGuard([UserPermission.ViewDashboard], 'all')], component: Home, }, { diff --git a/src/app/user/permission-guard.spec.ts b/src/app/user/permission-guard.spec.ts new file mode 100644 index 0000000..861f306 --- /dev/null +++ b/src/app/user/permission-guard.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; + +import { permissionGuard } from './permission-guard'; +import { UserStore } from './user-store'; +import { UserPermission } from './user-permission'; + +describe('permissionGuard', () => { + const executeGuard = ( + permissions: UserPermission[], + mode?: 'any' | 'all', + ): CanActivateFn => { + return (...guardParameters) => + TestBed.runInInjectionContext(() => permissionGuard(permissions, mode)(...guardParameters)); + }; + + it('should be created', () => { + expect(permissionGuard).toBeTruthy(); + }); + + it('should return true when all required permissions are present in all mode', () => { + const mockUserStore = { + user: vi.fn().mockReturnValue({ + permissions: [UserPermission.ViewDashboard], + }), + }; + const mockRouter = { + createUrlTree: vi.fn().mockReturnValue({ path: '/forbidden' }), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: UserStore, useValue: mockUserStore }, + { provide: Router, useValue: mockRouter }, + ], + }); + + const guard = executeGuard([UserPermission.ViewDashboard], 'all'); + expect(guard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot)).toBe(true); + }); + + it('should return url tree when one required permission is missing in all mode', () => { + const forbiddenTree = { path: '/forbidden' }; + const mockUserStore = { + user: vi.fn().mockReturnValue({ + permissions: [], + }), + }; + const mockRouter = { + createUrlTree: vi.fn().mockReturnValue(forbiddenTree), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: UserStore, useValue: mockUserStore }, + { provide: Router, useValue: mockRouter }, + ], + }); + + const guard = executeGuard([UserPermission.ViewDashboard], 'all'); + expect(guard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot)).toBe(forbiddenTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/forbidden']); + }); + + it('should return true when at least one required permission is present in any mode', () => { + const mockUserStore = { + user: vi.fn().mockReturnValue({ + permissions: [UserPermission.ViewDashboard], + }), + }; + const mockRouter = { + createUrlTree: vi.fn().mockReturnValue({ path: '/forbidden' }), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: UserStore, useValue: mockUserStore }, + { provide: Router, useValue: mockRouter }, + ], + }); + + const guard = executeGuard([UserPermission.ViewDashboard], 'any'); + expect(guard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot)).toBe(true); + }); + + it('should default to all mode when mode is not provided', () => { + const forbiddenTree = { path: '/forbidden' }; + const mockUserStore = { + user: vi.fn().mockReturnValue({ + permissions: [], + }), + }; + const mockRouter = { + createUrlTree: vi.fn().mockReturnValue(forbiddenTree), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: UserStore, useValue: mockUserStore }, + { provide: Router, useValue: mockRouter }, + ], + }); + + const guard = executeGuard([UserPermission.ViewDashboard]); + expect(guard({} as ActivatedRouteSnapshot, {} as RouterStateSnapshot)).toBe(forbiddenTree); + }); +}); diff --git a/src/app/user/permission-guard.ts b/src/app/user/permission-guard.ts new file mode 100644 index 0000000..5132ab8 --- /dev/null +++ b/src/app/user/permission-guard.ts @@ -0,0 +1,28 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { UserPermission } from './user-permission'; +import { UserStore } from './user-store'; + +export type PermissionGuardMode = 'any' | 'all'; + +export const permissionGuard = ( + requiredPermissions: UserPermission[], + mode: PermissionGuardMode = 'all', +): CanActivateFn => { + return () => { + const userStore = inject(UserStore); + const router = inject(Router); + const userPermissions = userStore.user()?.permissions ?? []; + + if (requiredPermissions.length === 0) { + return true; + } + + const hasRequiredPermissions = + mode === 'any' + ? requiredPermissions.some((permission) => userPermissions.includes(permission)) + : requiredPermissions.every((permission) => userPermissions.includes(permission)); + + return hasRequiredPermissions ? true : router.createUrlTree(['/forbidden']); + }; +}; diff --git a/src/app/user/user-permission.ts b/src/app/user/user-permission.ts new file mode 100644 index 0000000..df46c7d --- /dev/null +++ b/src/app/user/user-permission.ts @@ -0,0 +1,3 @@ +export enum UserPermission { + ViewDashboard = 'view:dashboard', +} diff --git a/src/app/user/user.ts b/src/app/user/user.ts index d114651..746a722 100644 --- a/src/app/user/user.ts +++ b/src/app/user/user.ts @@ -1,6 +1,9 @@ +import { UserPermission } from './user-permission'; + export interface User { id: string; username: string; createdAt: Date; avatar: string; + permissions: UserPermission[]; } From bf133b2c1086a587043d532a0b24a73ec83ce919 Mon Sep 17 00:00:00 2001 From: admclamb Date: Thu, 2 Apr 2026 01:05:36 -0400 Subject: [PATCH 3/3] setup guards --- src/app/user/permission-guard.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app/user/permission-guard.spec.ts b/src/app/user/permission-guard.spec.ts index 861f306..d692cf0 100644 --- a/src/app/user/permission-guard.spec.ts +++ b/src/app/user/permission-guard.spec.ts @@ -11,10 +11,7 @@ import { UserStore } from './user-store'; import { UserPermission } from './user-permission'; describe('permissionGuard', () => { - const executeGuard = ( - permissions: UserPermission[], - mode?: 'any' | 'all', - ): CanActivateFn => { + const executeGuard = (permissions: UserPermission[], mode?: 'any' | 'all'): CanActivateFn => { return (...guardParameters) => TestBed.runInInjectionContext(() => permissionGuard(permissions, mode)(...guardParameters)); };