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:
Lorenz Hilpert
2025-11-10 17:00:39 +00:00
committed by Nino Righi
parent c5ea5ed3ec
commit 2e0853c91a
21 changed files with 1539 additions and 412 deletions

816
libs/core/auth/README.md Normal file
View 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

View 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: {},
},
];

View 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"
}
}
}

View 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';

View 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();
});
});
});

View 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(),
};
}
}

View 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);
});
});
});

View 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;
}
}
}

View File

@@ -0,0 +1,13 @@
export const Role = {
/**
* HSC
*/
CallCenter: 'CallCenter',
/**
* Filiale
*/
Store: 'Store',
} as const;
export type Role = (typeof Role)[keyof typeof Role];

View 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;
},
};
},
},
);

View 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(),
);

View 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"
}
]
}

View 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"]
}

View 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"]
}

View 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'],
},
},
}));