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
21 KiB
@isa/core/auth
Type-safe role-based authorization utilities with Angular signals integration for the ISA Frontend application.
Table of Contents
- Overview
- Features
- Quick Start
- Core Concepts
- API Reference
- Usage Examples
- Configuration
- Testing
- Architecture
- 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
Roleenum with autocomplete - ✅ Automatic JWT token parsing via
OAuthService - ✅ Declarative role-based rendering with
*ifRoledirective - ✅ Reactive updates using Angular signals
- ✅ Centralized role management
Features
- 🔐 Type-Safe Roles - Enum-based role definitions prevent typos
- 🎯 Declarative Templates -
*ifRoleand*ifNotRolestructural 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:
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:
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:
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:
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
Default Implementation:
- Automatically provided via
InjectionTokenfactory - 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:
constructor() {
effect(() => {
this.render(); // Re-render when ifRole/ifNotRole inputs change
});
}
API Reference
Role (Enum)
Type-safe role definitions for the application.
export const Role = {
CallCenter: 'CallCenter', // HSC users
Store: 'Store', // Store users
} as const;
Usage:
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:
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:
<!-- 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:
<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):
<!-- 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):
<!-- Only show if user has BOTH roles -->
<div *ifRole="[Role.Store, Role.CallCenter]">
Advanced features requiring both roles
</div>
Component Integration:
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.
export interface TokenProvider {
getClaimByKey(key: string): unknown;
}
Default Implementation:
Automatically provided via InjectionToken factory:
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:
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.
export function parseJwt(
token: string | null
): Record<string, unknown> | null
Parameters:
token- JWT token string or null
Returns: Parsed claims object or null
Example:
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
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
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
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)
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:
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:
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:
{
"sub": "user123",
"role": ["Store"],
"exp": 1234567890
}
Supported formats:
- Single role:
"role": "Store" - Multiple roles:
"role": ["Store", "CallCenter"]
Testing
Run Tests
# 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:
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:
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 constassertion - 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:
// 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 utilityangular-oauth2-oidc- OAuth2/OIDC authentication@isa/core/logging- Logging integration
Internal Dependencies
No other ISA libraries required beyond @isa/core/logging.
Import Path
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 - Project guidelines
- Testing Guidelines - Vitest setup
- Library Reference - All libraries
Related Libraries
@isa/core/logging- Structured logging@isa/core/config- Configuration management@isa/core/storage- State persistence
License: ISC Version: 1.0.0 Last Updated: 2025-01-10