mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 2016: feat(core/auth): add type-safe role-based authorization library
feat(core/auth): add type-safe role-based authorization library Created @isa/core/auth library with comprehensive role checking: - RoleService for programmatic hasRole() checks - IfRoleDirective for declarative *ifRole/*ifNotRole templates - Type-safe Role enum (CallCenter, Store) - TokenProvider abstraction with OAuth2 integration - Signal-based reactive rendering with Angular effects - Zero-configuration setup via InjectionToken factory Fixed Bug #5451: - Hide action buttons for HSC (CallCenter) users on reward order confirmation - Applied *ifNotRole="Role.CallCenter" to actions container - Actions now hidden while maintaining card visibility Testing: - 18/18 unit tests passing with Vitest - JUnit and Cobertura reporting configured - Complete test coverage for role checking logic Documentation: - Comprehensive README (817 lines) with API reference - Usage examples and architecture diagrams - Updated library-reference.md (62→63 libraries) Technical: - Handles both string and array JWT role formats - Integrated with @isa/core/logging - Standalone directive (no module imports) - Full TypeScript type safety Closes #5451 Related work items: #5451
This commit is contained in:
committed by
Nino Righi
parent
c5ea5ed3ec
commit
2e0853c91a
816
libs/core/auth/README.md
Normal file
816
libs/core/auth/README.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# @isa/core/auth
|
||||
|
||||
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [API Reference](#api-reference)
|
||||
- [Role (Enum)](#role-enum)
|
||||
- [RoleService](#roleservice)
|
||||
- [IfRoleDirective](#ifroledirective)
|
||||
- [TokenProvider](#tokenprovider)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [Configuration](#configuration)
|
||||
- [Testing](#testing)
|
||||
- [Architecture](#architecture)
|
||||
- [Dependencies](#dependencies)
|
||||
|
||||
## Overview
|
||||
|
||||
`@isa/core/auth` provides a lightweight, type-safe system for managing role-based authorization in Angular applications. Built with modern Angular patterns (signals, standalone components), it integrates seamlessly with OAuth2 authentication flows.
|
||||
|
||||
### The Problem It Solves
|
||||
|
||||
Traditional role-checking often involves:
|
||||
- ❌ String literals scattered throughout templates and components
|
||||
- ❌ No compile-time safety for role names
|
||||
- ❌ Manual token parsing and claim extraction
|
||||
- ❌ Repetitive conditional rendering logic
|
||||
|
||||
This library provides:
|
||||
- ✅ Type-safe `Role` enum with autocomplete
|
||||
- ✅ Automatic JWT token parsing via `OAuthService`
|
||||
- ✅ Declarative role-based rendering with `*ifRole` directive
|
||||
- ✅ Reactive updates using Angular signals
|
||||
- ✅ Centralized role management
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Type-Safe Roles** - Enum-based role definitions prevent typos
|
||||
- 🎯 **Declarative Templates** - `*ifRole` and `*ifNotRole` structural directives
|
||||
- ⚡ **Signal-Based** - Reactive role checking with Angular signals
|
||||
- 🔄 **Flexible Token Provider** - Injectable abstraction with OAuth2 default
|
||||
- 📝 **Comprehensive Logging** - Integrated with `@isa/core/logging`
|
||||
- 🧪 **Fully Tested** - 18 unit tests with Vitest
|
||||
- 🎨 **Standalone** - No module imports required
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Import the directive and Role enum:**
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<!-- Show content only for Store users -->
|
||||
<div *ifRole="Role.Store">
|
||||
<h2>Store Dashboard</h2>
|
||||
<!-- Store-specific features -->
|
||||
</div>
|
||||
|
||||
<!-- Show content only for CallCenter users -->
|
||||
<div *ifRole="Role.CallCenter">
|
||||
<h2>CallCenter Dashboard</h2>
|
||||
<!-- CallCenter-specific features -->
|
||||
</div>
|
||||
|
||||
<!-- Hide content from CallCenter users -->
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button>Complete Order</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {
|
||||
protected readonly Role = Role; // Expose to template
|
||||
}
|
||||
```
|
||||
|
||||
**2. Use RoleService programmatically:**
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav',
|
||||
template: `...`
|
||||
})
|
||||
export class NavComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
ngOnInit() {
|
||||
if (this.roleService.hasRole(Role.Store)) {
|
||||
// Enable store-specific navigation
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. No configuration needed!** The library automatically uses `OAuthService` to parse JWT tokens.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Role Enum
|
||||
|
||||
Roles are defined as a const object with TypeScript type safety:
|
||||
|
||||
```typescript
|
||||
export const Role = {
|
||||
CallCenter: 'CallCenter', // HSC (Hugendubel Service Center)
|
||||
Store: 'Store', // Store/Branch users
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Autocomplete in IDEs
|
||||
- Compile-time checking prevents invalid roles
|
||||
- Easy to extend with new roles
|
||||
|
||||
### Token Provider Pattern
|
||||
|
||||
The library uses an injectable `TokenProvider` abstraction to decouple from specific authentication implementations:
|
||||
|
||||
```typescript
|
||||
export interface TokenProvider {
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Default Implementation:**
|
||||
- Automatically provided via `InjectionToken` factory
|
||||
- Uses `OAuthService.getAccessToken()` to fetch JWT
|
||||
- Parses token using `parseJwt()` utility
|
||||
- No manual configuration required
|
||||
|
||||
### Signal-Based Reactivity
|
||||
|
||||
The `IfRoleDirective` uses Angular effects for automatic re-rendering when roles change:
|
||||
|
||||
```typescript
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.render(); // Re-render when ifRole/ifNotRole inputs change
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Role (Enum)
|
||||
|
||||
Type-safe role definitions for the application.
|
||||
|
||||
```typescript
|
||||
export const Role = {
|
||||
CallCenter: 'CallCenter', // HSC users
|
||||
Store: 'Store', // Store users
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```typescript
|
||||
import { Role } from '@isa/core/auth';
|
||||
|
||||
if (roleService.hasRole(Role.Store)) {
|
||||
// Type-safe!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### RoleService
|
||||
|
||||
Service for programmatic role checks.
|
||||
|
||||
#### Methods
|
||||
|
||||
##### `hasRole(role: Role | Role[]): boolean`
|
||||
|
||||
Check if the authenticated user has specific role(s).
|
||||
|
||||
**Parameters:**
|
||||
- `role` - Single role or array of roles to check (AND logic for arrays)
|
||||
|
||||
**Returns:** `true` if user has all specified roles, `false` otherwise
|
||||
|
||||
**Examples:**
|
||||
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
export class ExampleComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
checkAccess() {
|
||||
// Single role check
|
||||
if (this.roleService.hasRole(Role.Store)) {
|
||||
console.log('User is a store employee');
|
||||
}
|
||||
|
||||
// Multiple roles (AND logic)
|
||||
if (this.roleService.hasRole([Role.Store, Role.CallCenter])) {
|
||||
console.log('User has BOTH store AND call center access');
|
||||
}
|
||||
|
||||
// Multiple checks
|
||||
const isStore = this.roleService.hasRole(Role.Store);
|
||||
const isCallCenter = this.roleService.hasRole(Role.CallCenter);
|
||||
|
||||
if (isStore || isCallCenter) {
|
||||
console.log('User has at least one role (OR logic)');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Logging:**
|
||||
|
||||
The service logs all role checks at `debug` level:
|
||||
```
|
||||
[RoleService] Role check: Store => true
|
||||
[RoleService] Role check: Store, CallCenter => false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IfRoleDirective
|
||||
|
||||
Structural directive for declarative role-based rendering.
|
||||
|
||||
**Selector:** `[ifRole]`, `[ifRoleElse]`, `[ifNotRole]`, `[ifNotRoleElse]`
|
||||
|
||||
#### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `ifRole` | `Role \| Role[]` | Role(s) required to show template |
|
||||
| `ifRoleElse` | `TemplateRef` | Alternative template if user lacks role |
|
||||
| `ifNotRole` | `Role \| Role[]` | Role(s) that should NOT be present |
|
||||
| `ifNotRoleElse` | `TemplateRef` | Alternative template if user has role |
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic Usage:**
|
||||
|
||||
```html
|
||||
<!-- Show for Store users -->
|
||||
<div *ifRole="Role.Store">
|
||||
Store-specific content
|
||||
</div>
|
||||
|
||||
<!-- Show for CallCenter users -->
|
||||
<div *ifRole="Role.CallCenter">
|
||||
CallCenter-specific content
|
||||
</div>
|
||||
```
|
||||
|
||||
**With Else Template:**
|
||||
|
||||
```html
|
||||
<div *ifRole="Role.Store; else noAccess">
|
||||
<button>Complete Order</button>
|
||||
</div>
|
||||
|
||||
<ng-template #noAccess>
|
||||
<p>You don't have permission to complete orders</p>
|
||||
</ng-template>
|
||||
```
|
||||
|
||||
**Negation (`ifNotRole`):**
|
||||
|
||||
```html
|
||||
<!-- Hide from CallCenter users -->
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button>Release Reward</button>
|
||||
<button>Mark Not Found</button>
|
||||
<button>Cancel</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Multiple Roles (AND logic):**
|
||||
|
||||
```html
|
||||
<!-- Only show if user has BOTH roles -->
|
||||
<div *ifRole="[Role.Store, Role.CallCenter]">
|
||||
Advanced features requiring both roles
|
||||
</div>
|
||||
```
|
||||
|
||||
**Component Integration:**
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifNotRole="Role.CallCenter">
|
||||
<button (click)="completeOrder()">Complete</button>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ActionsComponent {
|
||||
// Expose Role to template
|
||||
protected readonly Role = Role;
|
||||
|
||||
completeOrder() {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TokenProvider
|
||||
|
||||
Injectable abstraction for JWT token parsing.
|
||||
|
||||
```typescript
|
||||
export interface TokenProvider {
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Default Implementation:**
|
||||
|
||||
Automatically provided via `InjectionToken` factory:
|
||||
|
||||
```typescript
|
||||
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
|
||||
'TOKEN_PROVIDER',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const oAuthService = inject(OAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => {
|
||||
const claims = parseJwt(oAuthService.getAccessToken());
|
||||
return claims?.[key] ?? null;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**Custom Provider (Advanced):**
|
||||
|
||||
Override the default implementation:
|
||||
|
||||
```typescript
|
||||
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
|
||||
|
||||
providers: [
|
||||
{
|
||||
provide: TOKEN_PROVIDER,
|
||||
useValue: {
|
||||
getClaimByKey: (key: string) => {
|
||||
// Custom token parsing logic
|
||||
return myCustomAuthService.getClaim(key);
|
||||
}
|
||||
} as TokenProvider
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### parseJwt()
|
||||
|
||||
Utility function to parse JWT tokens.
|
||||
|
||||
```typescript
|
||||
export function parseJwt(
|
||||
token: string | null
|
||||
): Record<string, unknown> | null
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `token` - JWT token string or null
|
||||
|
||||
**Returns:** Parsed claims object or null
|
||||
|
||||
**Example:**
|
||||
|
||||
```typescript
|
||||
import { parseJwt } from '@isa/core/auth';
|
||||
|
||||
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
|
||||
const claims = parseJwt(token);
|
||||
|
||||
console.log(claims?.['role']); // ['Store']
|
||||
console.log(claims?.['sub']); // User ID
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Conditional Navigation
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-menu',
|
||||
standalone: true,
|
||||
imports: [RouterLink, IfRoleDirective],
|
||||
template: `
|
||||
<nav>
|
||||
<!-- Store-only navigation -->
|
||||
<a *ifRole="Role.Store" routerLink="/inventory">
|
||||
Inventory Management
|
||||
</a>
|
||||
|
||||
<a *ifRole="Role.Store" routerLink="/store-orders">
|
||||
Store Orders
|
||||
</a>
|
||||
|
||||
<!-- CallCenter-only navigation -->
|
||||
<a *ifRole="Role.CallCenter" routerLink="/customer-service">
|
||||
Customer Service
|
||||
</a>
|
||||
|
||||
<!-- Show for both roles -->
|
||||
<a routerLink="/dashboard">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
export class SideMenuComponent {
|
||||
protected readonly Role = Role;
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Guard with RoleService
|
||||
|
||||
```typescript
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
export const storeGuard: CanActivateFn = () => {
|
||||
const roleService = inject(RoleService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (roleService.hasRole(Role.Store)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Redirect to unauthorized page
|
||||
return router.createUrlTree(['/unauthorized']);
|
||||
};
|
||||
|
||||
// Route configuration
|
||||
export const routes = [
|
||||
{
|
||||
path: 'inventory',
|
||||
component: InventoryComponent,
|
||||
canActivate: [storeGuard]
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Example 3: Computed Signals with Roles
|
||||
|
||||
```typescript
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
template: `
|
||||
@if (canManageInventory()) {
|
||||
<button (click)="openInventory()">Manage Inventory</button>
|
||||
}
|
||||
|
||||
@if (canProcessReturns()) {
|
||||
<button (click)="openReturns()">Process Returns</button>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class DashboardComponent {
|
||||
private readonly roleService = inject(RoleService);
|
||||
|
||||
// Computed permissions
|
||||
canManageInventory = computed(() =>
|
||||
this.roleService.hasRole(Role.Store)
|
||||
);
|
||||
|
||||
canProcessReturns = computed(() =>
|
||||
this.roleService.hasRole([Role.Store, Role.CallCenter])
|
||||
);
|
||||
|
||||
openInventory() { /* ... */ }
|
||||
openReturns() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### Example 4: Real-World Component (Reward Order Confirmation)
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
import { ButtonComponent } from '@isa/ui/buttons';
|
||||
|
||||
@Component({
|
||||
selector: 'checkout-confirmation-actions',
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective, ButtonComponent],
|
||||
template: `
|
||||
<div class="action-card">
|
||||
<div class="message">
|
||||
Please complete the order or select an action.
|
||||
</div>
|
||||
|
||||
<!-- Hide actions from CallCenter (HSC) users -->
|
||||
<div *ifNotRole="Role.CallCenter" class="actions">
|
||||
<select [(ngModel)]="selectedAction">
|
||||
<option value="collect">Release Reward</option>
|
||||
<option value="not-found">Not Found</option>
|
||||
<option value="cancel">Cancel</option>
|
||||
</select>
|
||||
|
||||
<button uiButton color="primary" (click)="complete()">
|
||||
Complete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class ConfirmationActionsComponent {
|
||||
protected readonly Role = Role;
|
||||
selectedAction = 'collect';
|
||||
|
||||
complete() {
|
||||
// Complete order logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Configuration (Recommended)
|
||||
|
||||
No configuration needed! The library automatically uses `OAuthService`:
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { IfRoleDirective, Role } from '@isa/core/auth';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
// Works out of the box!
|
||||
})
|
||||
export class MyComponent {}
|
||||
```
|
||||
|
||||
### Custom TokenProvider (Advanced)
|
||||
|
||||
Override the default token provider:
|
||||
|
||||
```typescript
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { TOKEN_PROVIDER, TokenProvider } from '@isa/core/auth';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{
|
||||
provide: TOKEN_PROVIDER,
|
||||
useFactory: () => {
|
||||
const customAuth = inject(CustomAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => customAuth.getClaim(key)
|
||||
} as TokenProvider;
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### JWT Token Structure
|
||||
|
||||
The library expects JWT tokens with a `role` claim:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user123",
|
||||
"role": ["Store"],
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
- Single role: `"role": "Store"`
|
||||
- Multiple roles: `"role": ["Store", "CallCenter"]`
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx nx test core-auth
|
||||
|
||||
# Run with coverage
|
||||
npx nx test core-auth --coverage.enabled=true
|
||||
|
||||
# Skip cache (fresh run)
|
||||
npx nx test core-auth --skip-nx-cache
|
||||
```
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
✓ src/lib/role.service.spec.ts (11 tests)
|
||||
✓ src/lib/if-role.directive.spec.ts (7 tests)
|
||||
|
||||
Test Files 2 passed (2)
|
||||
Tests 18 passed (18)
|
||||
```
|
||||
|
||||
### Testing in Your App
|
||||
|
||||
**Mock RoleService:**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RoleService, Role } from '@isa/core/auth';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
let roleService: RoleService;
|
||||
|
||||
beforeEach(() => {
|
||||
roleService = {
|
||||
hasRole: vi.fn().mockReturnValue(true)
|
||||
} as any;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: RoleService, useValue: roleService }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should show store content for store users', () => {
|
||||
vi.spyOn(roleService, 'hasRole').mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(MyComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(roleService.hasRole).toHaveBeenCalledWith(Role.Store);
|
||||
// Assert UI changes
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Mock TokenProvider:**
|
||||
|
||||
```typescript
|
||||
import { TOKEN_PROVIDER, TokenProvider, Role } from '@isa/core/auth';
|
||||
|
||||
const mockTokenProvider: TokenProvider = {
|
||||
getClaimByKey: vi.fn().mockReturnValue([Role.Store])
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: TOKEN_PROVIDER, useValue: mockTokenProvider }
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Design Patterns
|
||||
|
||||
**1. Token Provider Pattern**
|
||||
- Abstracts JWT parsing behind injectable interface
|
||||
- Allows custom implementations without changing consumers
|
||||
- Default factory provides OAuthService integration
|
||||
|
||||
**2. Signal-Based Reactivity**
|
||||
- Uses Angular signals for reactive role checks
|
||||
- Effect-driven template updates
|
||||
- Minimal re-renders with fine-grained reactivity
|
||||
|
||||
**3. Type-Safe Enum Pattern**
|
||||
- Const object with `as const` assertion
|
||||
- Provides autocomplete and compile-time safety
|
||||
- Prevents typos and invalid role strings
|
||||
|
||||
### Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ ┌─────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Components │ │ Route Guards │ │
|
||||
│ │ (Templates) │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ *ifRole │ hasRole() │
|
||||
│ ▼ ▼ │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ @isa/core/auth Library │
|
||||
│ ┌──────────────────┐ ┌─────────────────┐ │
|
||||
│ │ IfRoleDirective │ │ RoleService │ │
|
||||
│ │ (Signals) │──────▶│ (Injectable) │ │
|
||||
│ └──────────────────┘ └────────┬────────┘ │
|
||||
│ │ │
|
||||
│ hasRole(Role[]) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────┐ │
|
||||
│ │ TokenProvider │ │
|
||||
│ │ (InjectionToken) │ │
|
||||
│ └────────┬───────────┘ │
|
||||
│ │ │
|
||||
│ getClaimByKey('role') │
|
||||
│ │ │
|
||||
├───────────────────────────────────┼──────────────┤
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ OAuthService │ │
|
||||
│ │ (angular-oauth2-oidc) │ │
|
||||
│ └──────────┬───────────────┘ │
|
||||
│ │ │
|
||||
│ getAccessToken() │
|
||||
│ │ │
|
||||
└──────────────────────────┼────────────────────────┘
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ JWT Token │
|
||||
│ { role: ... }│
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Role Claim Handling
|
||||
|
||||
The library handles both single and multiple role formats:
|
||||
|
||||
```typescript
|
||||
// Single role (string)
|
||||
{ "role": "Store" }
|
||||
|
||||
// Multiple roles (array)
|
||||
{ "role": ["Store", "CallCenter"] }
|
||||
|
||||
// Internal normalization using coerceArray()
|
||||
const userRolesArray = coerceArray(userRoles);
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### External Dependencies
|
||||
|
||||
- **`@angular/core`** - Angular framework
|
||||
- **`@angular/cdk/coercion`** - Array coercion utility
|
||||
- **`angular-oauth2-oidc`** - OAuth2/OIDC authentication
|
||||
- **`@isa/core/logging`** - Logging integration
|
||||
|
||||
### Internal Dependencies
|
||||
|
||||
No other ISA libraries required beyond `@isa/core/logging`.
|
||||
|
||||
### Import Path
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RoleService,
|
||||
IfRoleDirective,
|
||||
Role,
|
||||
TokenProvider,
|
||||
TOKEN_PROVIDER,
|
||||
parseJwt
|
||||
} from '@isa/core/auth';
|
||||
```
|
||||
|
||||
**Path Alias:** `@isa/core/auth` → `libs/core/auth/src/index.ts`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLAUDE.md](../../../CLAUDE.md) - Project guidelines
|
||||
- [Testing Guidelines](../../../docs/guidelines/testing.md) - Vitest setup
|
||||
- [Library Reference](../../../docs/library-reference.md) - All libraries
|
||||
|
||||
## Related Libraries
|
||||
|
||||
- [`@isa/core/logging`](../logging/README.md) - Structured logging
|
||||
- [`@isa/core/config`](../config/README.md) - Configuration management
|
||||
- [`@isa/core/storage`](../storage/README.md) - State persistence
|
||||
|
||||
---
|
||||
|
||||
**License:** ISC
|
||||
**Version:** 1.0.0
|
||||
**Last Updated:** 2025-01-10
|
||||
34
libs/core/auth/eslint.config.cjs
Normal file
34
libs/core/auth/eslint.config.cjs
Normal file
@@ -0,0 +1,34 @@
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const baseConfig = require('../../../eslint.config.js');
|
||||
|
||||
module.exports = [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'lib',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'lib',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/core/auth/project.json
Normal file
20
libs/core/auth/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "core-auth",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/core/auth/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../coverage/libs/core/auth"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
libs/core/auth/src/index.ts
Normal file
10
libs/core/auth/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Core Auth Library
|
||||
*
|
||||
* Provides role-based authorization utilities for the ISA Frontend application.
|
||||
*/
|
||||
|
||||
export { RoleService } from './lib/role.service';
|
||||
export { IfRoleDirective } from './lib/if-role.directive';
|
||||
export { TokenProvider, TOKEN_PROVIDER, parseJwt } from './lib/token-provider';
|
||||
export { Role } from './lib/role';
|
||||
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal file
157
libs/core/auth/src/lib/if-role.directive.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { IfRoleDirective } from './if-role.directive';
|
||||
import { RoleService } from './role.service';
|
||||
import { Role } from './role';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifRole="role" data-test="content">Store Content</div>
|
||||
`,
|
||||
})
|
||||
class TestIfRoleComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifRole="role; else noAccess" data-test="content">Store Content</div>
|
||||
<ng-template #noAccess>
|
||||
<div data-test="else">No Access</div>
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
class TestIfRoleElseComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `
|
||||
<div *ifNotRole="role" data-test="content">Non-Store Content</div>
|
||||
`,
|
||||
})
|
||||
class TestIfNotRoleComponent {
|
||||
role = Role.Store;
|
||||
}
|
||||
|
||||
describe('IfRoleDirective', () => {
|
||||
let roleService: { hasRole: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
roleService = {
|
||||
hasRole: vi.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: RoleService, useValue: roleService }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('ifRole', () => {
|
||||
it('should render content when user has role', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content?.textContent).toContain('Store Content');
|
||||
});
|
||||
|
||||
it('should not render content when user does not have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render else template when user does not have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleElseComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
const elseContent = fixture.nativeElement.querySelector('[data-test="else"]');
|
||||
|
||||
expect(content).toBeFalsy();
|
||||
expect(elseContent).toBeTruthy();
|
||||
expect(elseContent?.textContent).toContain('No Access');
|
||||
});
|
||||
|
||||
it('should update when role input changes', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
let content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
|
||||
// Change role and mock to return false
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
fixture.componentInstance.role = Role.CallCenter;
|
||||
fixture.detectChanges();
|
||||
|
||||
content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ifNotRole', () => {
|
||||
it('should render content when user does NOT have role', () => {
|
||||
roleService.hasRole.mockReturnValue(false);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content?.textContent).toContain('Non-Store Content');
|
||||
});
|
||||
|
||||
it('should not render content when user has role', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
const fixture = TestBed.createComponent(TestIfNotRoleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple roles', () => {
|
||||
it('should handle array of roles', () => {
|
||||
roleService.hasRole.mockReturnValue(true);
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [IfRoleDirective],
|
||||
template: `<div *ifRole="roles" data-test="content">Content</div>`,
|
||||
})
|
||||
class TestMultipleRolesComponent {
|
||||
roles = [Role.Store, Role.CallCenter];
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(TestMultipleRolesComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(roleService.hasRole).toHaveBeenCalledWith([Role.Store, Role.CallCenter]);
|
||||
|
||||
const content = fixture.nativeElement.querySelector('[data-test="content"]');
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
105
libs/core/auth/src/lib/if-role.directive.ts
Normal file
105
libs/core/auth/src/lib/if-role.directive.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Directive,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
TemplateRef,
|
||||
ViewContainerRef,
|
||||
} from '@angular/core';
|
||||
import { RoleService } from './role.service';
|
||||
import { Role } from './role';
|
||||
|
||||
/**
|
||||
* Structural directive for role-based conditional rendering using Angular signals
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- Show content if user has role -->
|
||||
* <div *ifRole="Role.Store">Store content</div>
|
||||
*
|
||||
* <!-- Show content if user has multiple roles -->
|
||||
* <div *ifRole="[Role.Store, Role.CallCenter]">Multiple roles</div>
|
||||
*
|
||||
* <!-- Show alternate content if user doesn't have role -->
|
||||
* <div *ifRole="Role.Store; else noAccess">Store content</div>
|
||||
* <ng-template #noAccess>No access</ng-template>
|
||||
*
|
||||
* <!-- Show content if user does NOT have role -->
|
||||
* <div *ifNotRole="Role.CallCenter">Non-CallCenter content</div>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[ifRole],[ifRoleElse],[ifNotRole],[ifNotRoleElse]',
|
||||
standalone: true,
|
||||
})
|
||||
export class IfRoleDirective {
|
||||
private readonly _templateRef = inject(TemplateRef<{ $implicit: Role | Role[] }>);
|
||||
private readonly _viewContainer = inject(ViewContainerRef);
|
||||
private readonly _roleService = inject(RoleService);
|
||||
|
||||
/**
|
||||
* Role(s) required to show the template
|
||||
*/
|
||||
readonly ifRole = input<Role | Role[]>();
|
||||
|
||||
/**
|
||||
* Alternative template to show if user doesn't have ifRole
|
||||
*/
|
||||
readonly ifRoleElse = input<TemplateRef<unknown>>();
|
||||
|
||||
/**
|
||||
* Role(s) that should NOT be present to show the template
|
||||
*/
|
||||
readonly ifNotRole = input<Role | Role[]>();
|
||||
|
||||
/**
|
||||
* Alternative template to show if user has ifNotRole
|
||||
*/
|
||||
readonly ifNotRoleElse = input<TemplateRef<unknown>>();
|
||||
|
||||
constructor() {
|
||||
// Use effect to reactively update the view when inputs change
|
||||
effect(() => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private get renderTemplateRef(): boolean {
|
||||
const role = this.ifRole();
|
||||
const notRole = this.ifNotRole();
|
||||
|
||||
if (role) {
|
||||
return this._roleService.hasRole(role);
|
||||
}
|
||||
if (notRole) {
|
||||
return !this._roleService.hasRole(notRole);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private get elseTemplateRef(): TemplateRef<unknown> | undefined {
|
||||
return this.ifRoleElse() || this.ifNotRoleElse();
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (this.renderTemplateRef) {
|
||||
this._viewContainer.clear();
|
||||
this._viewContainer.createEmbeddedView(this._templateRef, this.getContext());
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.elseTemplateRef) {
|
||||
this._viewContainer.clear();
|
||||
this._viewContainer.createEmbeddedView(this.elseTemplateRef, this.getContext());
|
||||
return;
|
||||
}
|
||||
|
||||
this._viewContainer.clear();
|
||||
}
|
||||
|
||||
private getContext(): { $implicit: Role | Role[] | undefined } {
|
||||
return {
|
||||
$implicit: this.ifRole() || this.ifNotRole(),
|
||||
};
|
||||
}
|
||||
}
|
||||
95
libs/core/auth/src/lib/role.service.spec.ts
Normal file
95
libs/core/auth/src/lib/role.service.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RoleService } from './role.service';
|
||||
import { TOKEN_PROVIDER, TokenProvider } from './token-provider';
|
||||
import { Role } from './role';
|
||||
|
||||
describe('RoleService', () => {
|
||||
let service: RoleService;
|
||||
let tokenProvider: TokenProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenProvider = {
|
||||
getClaimByKey: vi.fn(),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [RoleService, { provide: TOKEN_PROVIDER, useValue: tokenProvider }],
|
||||
});
|
||||
|
||||
service = TestBed.inject(RoleService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('hasRole', () => {
|
||||
it('should return true when user has single required role', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store, Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user does not have required role', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user has all required roles (array)', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([
|
||||
Role.Store,
|
||||
Role.CallCenter,
|
||||
]);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user is missing one of required roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.CallCenter]);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user has no roles in token', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(null);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user has undefined roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(undefined);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockImplementation(() => {
|
||||
throw new Error('Token parsing error');
|
||||
});
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty role array', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue([Role.Store]);
|
||||
|
||||
expect(service.hasRole([])).toBe(true); // empty array means no requirements
|
||||
});
|
||||
|
||||
it('should handle single role as string (not array)', () => {
|
||||
// JWT might return a single string instead of array for single role
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
|
||||
|
||||
expect(service.hasRole(Role.Store)).toBe(true);
|
||||
expect(service.hasRole(Role.CallCenter)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle single role string when checking multiple roles', () => {
|
||||
vi.spyOn(tokenProvider, 'getClaimByKey').mockReturnValue(Role.Store);
|
||||
|
||||
expect(service.hasRole([Role.Store, Role.CallCenter])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
71
libs/core/auth/src/lib/role.service.ts
Normal file
71
libs/core/auth/src/lib/role.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { coerceArray } from '@angular/cdk/coercion';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { TOKEN_PROVIDER } from './token-provider';
|
||||
import { Role } from './role';
|
||||
|
||||
/**
|
||||
* Service for role-based authorization checks
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Role } from '@isa/core/auth';
|
||||
*
|
||||
* const roleService = inject(RoleService);
|
||||
* if (roleService.hasRole(Role.Store)) {
|
||||
* // Show store features
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RoleService {
|
||||
private readonly _log = logger({ service: 'RoleService' });
|
||||
private readonly _tokenProvider = inject(TOKEN_PROVIDER);
|
||||
|
||||
/**
|
||||
* Check if the authenticated user has specific role(s)
|
||||
*
|
||||
* @param role Single role or array of roles to check
|
||||
* @returns true if user has all specified roles, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Role } from '@isa/core/auth';
|
||||
*
|
||||
* // Check single role
|
||||
* hasRole(Role.Store) // true if user has Store role
|
||||
*
|
||||
* // Check multiple roles (AND logic)
|
||||
* hasRole([Role.Store, Role.CallCenter]) // true only if user has BOTH roles
|
||||
* ```
|
||||
*/
|
||||
hasRole(role: Role | Role[]): boolean {
|
||||
const roles = coerceArray(role);
|
||||
|
||||
try {
|
||||
const userRoles = this._tokenProvider.getClaimByKey('role');
|
||||
|
||||
if (!userRoles) {
|
||||
this._log.debug('No roles found in token claims');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Coerce userRoles to array in case it's a single string
|
||||
const userRolesArray = coerceArray(userRoles);
|
||||
|
||||
const hasAllRoles = roles.every((r) => userRolesArray.includes(r));
|
||||
|
||||
this._log.debug(`Role check: ${roles.join(', ')} => ${hasAllRoles}`, () => ({
|
||||
requiredRoles: roles,
|
||||
userRoles: userRolesArray,
|
||||
}));
|
||||
|
||||
return hasAllRoles;
|
||||
} catch (error) {
|
||||
this._log.error('Error checking roles', error as Error, () => ({ requiredRoles: roles }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
libs/core/auth/src/lib/role.ts
Normal file
13
libs/core/auth/src/lib/role.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const Role = {
|
||||
/**
|
||||
* HSC
|
||||
*/
|
||||
CallCenter: 'CallCenter',
|
||||
|
||||
/**
|
||||
* Filiale
|
||||
*/
|
||||
Store: 'Store',
|
||||
} as const;
|
||||
|
||||
export type Role = (typeof Role)[keyof typeof Role];
|
||||
67
libs/core/auth/src/lib/token-provider.ts
Normal file
67
libs/core/auth/src/lib/token-provider.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { inject, InjectionToken } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
/**
|
||||
* Token provider interface for role checking
|
||||
* The app can provide a custom implementation that returns user roles from the auth token
|
||||
*/
|
||||
export interface TokenProvider {
|
||||
/**
|
||||
* Get a claim value from the authentication token
|
||||
* @param key The claim key (e.g., 'role')
|
||||
* @returns The claim value or null if not found
|
||||
*/
|
||||
getClaimByKey(key: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
export function parseJwt(token: string | null): Record<string, unknown> | null {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const encoded = window.atob(base64);
|
||||
return JSON.parse(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for TokenProvider with default OAuthService implementation
|
||||
*
|
||||
* By default, this uses OAuthService to extract claims from the access token.
|
||||
* You can override this by providing your own implementation:
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* providers: [
|
||||
* {
|
||||
* provide: TOKEN_PROVIDER,
|
||||
* useValue: {
|
||||
* getClaimByKey: (key) => customTokenService.getClaim(key)
|
||||
* }
|
||||
* }
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
export const TOKEN_PROVIDER = new InjectionToken<TokenProvider>(
|
||||
'TOKEN_PROVIDER',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const oAuthService = inject(OAuthService);
|
||||
return {
|
||||
getClaimByKey: (key: string) => {
|
||||
const claims = parseJwt(oAuthService.getAccessToken());
|
||||
return claims?.[key] ?? null;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
13
libs/core/auth/src/test-setup.ts
Normal file
13
libs/core/auth/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-zone';
|
||||
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting(),
|
||||
);
|
||||
30
libs/core/auth/tsconfig.json
Normal file
30
libs/core/auth/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/core/auth/tsconfig.lib.json
Normal file
27
libs/core/auth/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
29
libs/core/auth/tsconfig.spec.json
Normal file
29
libs/core/auth/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
33
libs/core/auth/vite.config.mts
Normal file
33
libs/core/auth/vite.config.mts
Normal file
@@ -0,0 +1,33 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default
|
||||
// @ts-expect-error - Vitest reporter tuple types have complex inference issues
|
||||
defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/core/auth',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: [
|
||||
'default',
|
||||
['junit', { outputFile: '../../../testresults/junit-core-auth.xml' }],
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/core/auth',
|
||||
provider: 'v8' as const,
|
||||
reporter: ['text', 'cobertura'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user