Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,27 @@ Platform capabilities at a glance:
### Customer Portal — My Claims
> Policy holder view: submitted invoices with CHF reimbursement amounts and colour-coded status chips.

![My Claims](docs/claims_customer.png)
![My Claims](docs/customer_main_page.png)

### Submit a New Claim
> PDF invoice upload form — the OCR + validation pipeline triggers automatically on submission.

![Submit Claim](docs/submit_new_claim_dialog.png)
![Submit Claim](docs/customer_upload_invoice_dialog.png)

### Backoffice — Review Queue (MANUAL\_REVIEW\_REQUIRED)
> Role-gated reviewer dashboard showing claims flagged by the rules engine for manual intervention.
### Backoffice — Review Queue
> Role-gated reviewer dashboard showing all claims. The button-toggle filter switches between pipeline states.

![Review Queue](docs/review_queue_manual_review_required.png)
![Review Queue](docs/backoffice_main_page.png)

### Backoffice — Review Queue (PENDING OCR)
> Same dashboard filtered to PENDING OCR — shows claims awaiting document extraction.

![Pending OCR](docs/backoffice_pending_ocr.png)

### Backoffice — Inline Approve / Reject
> Expanding a claim row reveals the review panel: flagged reason, reviewer notes, and decision buttons.
> Expanding a flagged claim reveals the review panel: flagged reason, reviewer notes, and decision buttons.

![Approve or Reject](docs/review_wueue_manual_review_aprove_or_reject.png)
![Approve or Reject](docs/backoffice_validate_claim.png)

---

Expand Down
Binary file added docs/backoffice_main_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/backoffice_pending_ocr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/backoffice_validate_claim.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/claims_backoffice_manual_review.png
Binary file not shown.
Binary file removed docs/claims_customer.png
Binary file not shown.
Binary file added docs/customer_main_page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/customer_upload_invoice_dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/review_queue_aproved.png
Binary file not shown.
Binary file removed docs/review_queue_manual_review_required.png
Binary file not shown.
Binary file removed docs/review_queue_ocr_processing.png
Binary file not shown.
Binary file removed docs/review_queue_pending_ocr.png
Binary file not shown.
Binary file removed docs/review_wueue_manual_review_aprove_or_reject.png
Binary file not shown.
Binary file removed docs/submit_new_claim_dialog.png
Binary file not shown.
10 changes: 6 additions & 4 deletions frontend/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
</button>
}

<button mat-button routerLink="/portal/claims">
<mat-icon>receipt_long</mat-icon>
My Claims
</button>
@if (!authService.isBackoffice()) {
<button mat-button routerLink="/portal/claims">
<mat-icon>receipt_long</mat-icon>
My Claims
</button>
}

<button mat-icon-button (click)="authService.logout()" aria-label="Logout">
<mat-icon>logout</mat-icon>
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/app/core/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ const mockKcInstance = vi.hoisted(() => ({
realmAccess: undefined as { roles: string[] } | undefined,
token: undefined as string | undefined,
login: vi.fn(),
logout: vi.fn(),
logout: vi.fn().mockResolvedValue(undefined),
updateToken: vi.fn().mockResolvedValue(true),
loadUserProfile: vi.fn().mockResolvedValue({ firstName: undefined as string | undefined, username: undefined as string | undefined }),
}));

// The factory must use a regular function so it can be called with `new`.
Expand All @@ -35,6 +36,7 @@ describe('AuthService', () => {
mockKcInstance.idTokenParsed = null;
mockKcInstance.realmAccess = undefined;
mockKcInstance.token = undefined;
mockKcInstance.loadUserProfile.mockResolvedValue({ firstName: undefined, username: undefined });

TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
Expand Down Expand Up @@ -69,18 +71,18 @@ describe('AuthService', () => {
describe('after a successful init', () => {
beforeEach(async () => {
mockKcInstance.init.mockResolvedValue(true);
mockKcInstance.tokenParsed = { preferred_username: 'joao', sub: 'user-uuid-001' };
mockKcInstance.idTokenParsed = { sub: 'user-uuid-001' };
mockKcInstance.realmAccess = { roles: ['user', 'backoffice'] };
mockKcInstance.subject = 'user-uuid-001';
mockKcInstance.loadUserProfile.mockResolvedValue({ firstName: 'joao', username: 'joao' });
await service.init();
});

it('sets authenticated to true', () => {
expect(service.authenticated()).toBe(true);
});

it('sets username from the preferred_username claim', () => {
it('sets username from the user profile', () => {
expect(service.username()).toBe('joao');
});

Expand Down
20 changes: 16 additions & 4 deletions frontend/src/app/core/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,17 @@ export class AuthService {
if (authenticated) {
// Access tokens in this realm are opaque — all user claims come from the ID token.
const idToken = this.kc.idTokenParsed as Record<string, unknown> | undefined;
this._username.set(
(idToken?.['preferred_username'] ?? this.kc.tokenParsed?.['preferred_username']) as string | undefined
);

// Keycloak 26 omits profile claims from the ID token by default when access
// tokens are opaque. loadUserProfile() hits /account directly — most reliable.
const nameFromToken = (idToken?.['given_name'] ?? idToken?.['name'] ?? idToken?.['preferred_username']) as string | undefined;
if (nameFromToken) {
this._username.set(nameFromToken);
} else {
const profile = await this.kc.loadUserProfile().catch(() => null);
this._username.set(profile?.firstName ?? profile?.username);
}

const realmAccess = (idToken?.['realm_access'] ?? this.kc.realmAccess) as { roles?: string[] } | undefined;
this._roles.set(realmAccess?.roles ?? []);
this._policyHolderId.set(
Expand All @@ -45,7 +53,11 @@ export class AuthService {
}

logout(): void {
this.kc.logout({ redirectUri: window.location.origin });
// If the id_token_hint is stale (e.g. after a Keycloak realm reset), the
// OIDC logout endpoint rejects it. Fall back to a hard redirect so the
// app re-enters the login-required flow cleanly.
this.kc.logout({ redirectUri: window.location.origin })
.catch(() => { window.location.assign(window.location.origin); });
}

async getToken(): Promise<string> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<div class="page-card">
<div class="page-header">
<h1>Review Queue</h1>
<mat-button-toggle-group
Expand Down Expand Up @@ -115,3 +116,4 @@ <h3 class="review-title">Manual Review Decision</h3>

</mat-table>
}
</div>
7 changes: 6 additions & 1 deletion frontend/src/app/features/portal/my-claims/my-claims.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<div class="page-card">
<div class="page-header">
<h1>My Claims</h1>
<div>
<h1>Hello, {{ authService.username() }}!</h1>
<p class="page-subtitle">Your submitted claims</p>
</div>
<button mat-raised-button color="primary" routerLink="/portal/submit">
<mat-icon>add</mat-icon>
Submit New Claim
Expand Down Expand Up @@ -42,3 +46,4 @@ <h1>My Claims</h1>
<mat-row *matRowDef="let row; columns: displayedColumns" />
</mat-table>
}
</div>
8 changes: 7 additions & 1 deletion frontend/src/app/features/portal/my-claims/my-claims.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
margin-bottom: 24px;

h1 {
margin: 0;
margin: 0 0 2px;
}
}

.page-subtitle {
margin: 0;
font-size: 14px;
color: var(--mat-sys-on-surface-variant);
}

.center {
display: flex;
justify-content: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function renderMyClaims({ policyHolderId = 'user-001', claims = [], apiError = f
return render(MyClaims, {
providers: [
provideRouter([]),
{ provide: AuthService, useValue: { policyHolderId } },
{ provide: AuthService, useValue: { policyHolderId, username: () => 'Jose' } },
{ provide: ClaimsApiService, useValue: { getByPolicyHolder: vi.fn().mockReturnValue(claimsApi$) } },
],
});
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/features/portal/my-claims/my-claims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { Claim } from '../../../core/api/models/claim.model';
})
export class MyClaims implements OnInit {
private readonly claimsApi = inject(ClaimsApiService);
private readonly authService = inject(AuthService);
protected readonly authService = inject(AuthService);

readonly claims = signal<Claim[]>([]);
readonly loading = signal(true);
Expand Down
13 changes: 11 additions & 2 deletions frontend/src/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,21 @@ body {
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
background-color: var(--mat-sys-surface-container-low);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);

// Reset the user agent margin.
margin: 0;
height: 100%;
}
/* You can add global styles to this file, and also import other style files */

// Shared card surface for table/list pages.
// Gray canvas (body) + white card = standard SaaS depth.
.page-card {
background: var(--mat-sys-surface);
border-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
overflow: hidden; // clips table header to card corner radius
padding: 24px;
}
6 changes: 4 additions & 2 deletions keycloak/insurtech-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@
],
"users": [
{
"id": "6a35eebc-9170-4d43-b468-37a7207ebab1",
"username": "backoffice",
"email": "backoffice@insurtech-demo.ch",
"firstName": "Backoffice",
"firstName": "Romen",
"lastName": "Analyst",
"enabled": true,
"emailVerified": true,
Expand All @@ -104,9 +105,10 @@
]
},
{
"id": "d22c39e7-d4ac-45ca-b7b7-e6fc5d23c81b",
"username": "customer",
"email": "customer@insurtech-demo.ch",
"firstName": "Maria",
"firstName": "Jose",
"lastName": "Muster",
"enabled": true,
"emailVerified": true,
Expand Down
Loading