Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
da9c478
fix postgres
mohamedalaaser Feb 24, 2026
ea0ecb1
managing degree programs and specializations
mohamedalaaser Mar 1, 2026
ad4c35f
fix
mohamedalaaser Mar 1, 2026
7d79b14
module creation steps
mohamedalaaser Mar 8, 2026
fc0d56a
fixes
mohamedalaaser Mar 8, 2026
90237af
fix
mohamedalaaser Mar 8, 2026
0541fbe
first iteration without showing feedbacks
mohamedalaaser Mar 17, 2026
3d22936
rename
mohamedalaaser Mar 17, 2026
e6f44e9
show feedbacks
mohamedalaaser Mar 18, 2026
3c1e5a4
invalidate previous feedbacks when needed
mohamedalaaser Mar 18, 2026
f7c20a9
fix
mohamedalaaser Mar 18, 2026
54500e7
imrpove feedback view
mohamedalaaser Mar 19, 2026
d881878
fix
mohamedalaaser Mar 19, 2026
66637dd
display new module fields in feedback
mohamedalaaser Mar 19, 2026
58f7742
imrpove feedback given visualisation
mohamedalaaser Mar 19, 2026
6f7916f
cleanup
mohamedalaaser Mar 19, 2026
b316bb8
ui improvements for feedback messages
mohamedalaaser Mar 20, 2026
a80760e
cleanup stepper
mohamedalaaser Mar 20, 2026
32361b0
fix
mohamedalaaser Mar 20, 2026
5328ea3
stepper feedback status adjustment
mohamedalaaser Mar 21, 2026
674f3ef
proposal and module version status updates
mohamedalaaser Mar 21, 2026
15dd1d4
status adjustment
mohamedalaaser Mar 21, 2026
333834d
fix
mohamedalaaser Mar 21, 2026
6fc6640
Program directors and areas responsibles (#76)
mohamedalaaser Mar 18, 2026
b1cf527
Merge branch 'main' into step-by-step-module-creation
mohamedalaaser Mar 21, 2026
d1c606e
fix
mohamedalaaser Mar 21, 2026
4345e6c
email setup
mohamedalaaser Mar 21, 2026
dfd270c
small comment
mohamedalaaser Mar 21, 2026
5a3669e
no legacy-peer-deps in client docker build
mohamedalaaser Mar 21, 2026
4d27b40
add mailpit to prod
mohamedalaaser Mar 21, 2026
e25feb2
use keycloak passkey extension
mohamedalaaser Mar 21, 2026
d76e944
sign in with passkey
mohamedalaaser Mar 21, 2026
cc620d9
cleanup
mohamedalaaser Mar 21, 2026
fd0a924
passkey popup
mohamedalaaser Mar 21, 2026
1eb42bf
sign in component
mohamedalaaser Mar 22, 2026
9b95850
Merge branch 'main' into use-keycloak-passkey-extension
mohamedalaaser Mar 22, 2026
46b27d3
Fix passkey extension session / key management
ITegs Mar 24, 2026
409216b
Move to structured passkey extension
ITegs Mar 24, 2026
a548715
Use keycloak passkey extension (#79)
mohamedalaaser Mar 25, 2026
3fea383
Fix chromium cors issue
ITegs Mar 25, 2026
5292edb
Update keycloak passkey extension; Adapt keycloak.service.ts to match…
ITegs Mar 31, 2026
c24d25c
Merge branch 'main' into use-keycloak-passkey-extension
ITegs Mar 31, 2026
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
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Slim context for docker/keycloak/Dockerfile (build context: Module-Management root)
Client/node_modules
Client/dist
Server/build
Server/.gradle
**/.git
.git
2 changes: 2 additions & 0 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ EMBEDDING_MODEL_NAME= sentence-transformers/all-mpnet-base-v2
CHAT_MODEL_SOURCE = OpenAiChatModel
EMBEDDING_MODEL_SOURCE = OpenAiEmbeddingModel

KC_PASSKEY_CLIENT_ID=module-management
KC_ALLOWED_BROWSER_ORIGIN=https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?|https://module\.aet\.cit\.tum\.de
17 changes: 1 addition & 16 deletions Client/src/app/components/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@
<a class="flex gap-2 text-xl font-semibold" routerLink="/">CIT Module Management</a>
</div>

<!-- Right Side: Theme Toggle, Sign In/Out -->
<div class="flex items-center gap-4">
<!-- Theme Toggle Button -->
<p-button
[icon]="isDarkMode() ? 'pi pi-sun' : 'pi pi-moon'"
severity="contrast"
Expand All @@ -24,21 +22,8 @@
tooltipPosition="bottom"
[ariaLabel]="isDarkMode() ? 'Switch to Light Mode' : 'Switch to Dark Mode'"
/>

<!-- If the user is signed in -->
@if (user() !== undefined) {
<p-button
#userButton
[label]="user()?.firstName + ' ' + user()?.lastName"
severity="contrast"
[outlined]="true"
(onClick)="menu.toggle($event)"
[style]="{ 'min-width': '150px' }"
/>

<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body" [style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }" />
} @else {
<p-button label="Sign In" severity="contrast" (onClick)="signIn()" />
<app-sign-in />
}
</div>
</header>
30 changes: 2 additions & 28 deletions Client/src/app/components/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ import { SecurityStore } from '../../core/security/security-store.service';
import { ThemeService } from '../../core/theme/theme.service';
import { SidebarService } from '../side-bar/sidebar.service';
import { ButtonModule } from 'primeng/button';
import { AvatarModule } from 'primeng/avatar';
import { MenuModule } from 'primeng/menu';
import { TooltipModule } from 'primeng/tooltip';
import { MenuItem } from 'primeng/api';
import { SignInComponent } from '../sign-in/sign-in.component';

@Component({
selector: 'app-header',
templateUrl: './header.component.html',
standalone: true,
imports: [RouterLink, ButtonModule, AvatarModule, MenuModule, TooltipModule]
imports: [RouterLink, ButtonModule, TooltipModule, SignInComponent]
})
export class HeaderComponent {
securityStore = inject(SecurityStore);
Expand All @@ -23,31 +21,7 @@ export class HeaderComponent {
user = this.securityStore.user;
isDarkMode = this.themeService.isDarkMode;

menuItems: MenuItem[] = [
{
label: 'Settings',
icon: 'pi pi-cog',
routerLink: '/account'
},
{
separator: true
},
{
label: 'Sign Out',
icon: 'pi pi-sign-out',
command: () => this.signOut()
}
];

toggleTheme() {
this.themeService.toggleTheme();
}

signIn() {
this.securityStore.signIn();
}

signOut() {
this.securityStore.signOut();
}
}
47 changes: 47 additions & 0 deletions Client/src/app/components/sign-in/sign-in.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@if (user() !== undefined) {
<p-button
#userButton
[label]="user()?.firstName + ' ' + user()?.lastName"
severity="contrast"
[outlined]="true"
(onClick)="menu.toggle($event)"
[style]="{ 'min-width': '150px' }"
/>

<p-menu #menu [model]="menuItems" [popup]="true" appendTo="body"
[style]="{ 'min-width': userButton.el.nativeElement.offsetWidth + 'px' }"/>
} @else {
@if (securityStore.isLoading()) {
<div class="flex items-center justify-center gap-2 pb-1 text-xs text-muted-foreground">
<i class="pi pi-spinner pi-spin"></i>
<span>Loading...</span>
</div>
} @else {
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
<p-button
label="Sign in with Passkey"
severity="contrast"
[ariaLabel]="'Sign in with passkey'"
pTooltip="Sign in with passkey"
tooltipPosition="bottom"
[disabled]="securityStore.isLoading()"
(onClick)="securityStore.signInWithPasskey()"
[style]="{ width: '220px' }"
/>
<div class="flex items-center gap-3" aria-hidden="true">
<span class="h-px flex-1 bg-gray-400/70 dark:bg-gray-500/80"></span>
<span class="text-xs uppercase tracking-wide text-muted-foreground">or</span>
<span class="h-px flex-1 bg-gray-400/70 dark:bg-gray-500/80"></span>
</div>
<p-button
label="Sign in with TUM"
severity="contrast"
(onClick)="securityStore.signInWithTum()"
pTooltip="Sign in with TUM"
tooltipPosition="bottom"
[disabled]="securityStore.isLoading()"
[style]="{ width: '220px' }"
/>
</div>
}
}
34 changes: 34 additions & 0 deletions Client/src/app/components/sign-in/sign-in.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Component, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ButtonModule } from 'primeng/button';
import { MenuModule } from 'primeng/menu';
import { TooltipModule } from 'primeng/tooltip';
import { MenuItem } from 'primeng/api';
import { SecurityStore } from '../../core/security/security-store.service';

@Component({
selector: 'app-sign-in',
standalone: true,
imports: [RouterModule, ButtonModule, MenuModule, TooltipModule],
templateUrl: './sign-in.component.html'
})
export class SignInComponent {
readonly securityStore = inject(SecurityStore);
readonly user = this.securityStore.user;

menuItems: MenuItem[] = [
{
label: 'Settings',
icon: 'pi pi-cog',
routerLink: '/account'
},
{
separator: true
},
{
label: 'Sign Out',
icon: 'pi pi-sign-out',
command: () => this.securityStore.signOut()
}
];
}
172 changes: 125 additions & 47 deletions Client/src/app/core/security/keycloak.service.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,143 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../../../environments/environment';
import {inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {environment} from '../../../../environments/environment';
import Keycloak from 'keycloak-js';
import { KeycloakCredentialType } from './keycloak-credentials.types';
import {KeycloakCredentialType} from './keycloak-credentials.types';

@Injectable({ providedIn: 'root' })
function toBase64Url(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

function fromBase64Url(value: string) {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}

@Injectable({providedIn: 'root'})
export class KeycloakService {
private http = inject(HttpClient);
_keycloak: Keycloak | undefined;
checkSsoOptions = {
onLoad: 'check-sso' as const,
pkceMethod: 'S256' as const,
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
silentCheckSsoFallback: false
};
private readonly http = inject(HttpClient);

get keycloak() {
private _keycloak?: Keycloak;

get keycloak(): Keycloak {
if (!this._keycloak) {
this._keycloak = new Keycloak({
url: environment.keycloak.url,
realm: environment.keycloak.realm,
clientId: environment.keycloak.clientId
});
throw new Error('Keycloak not initialized');
}
return this._keycloak;
}

async init() {
return await this.keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
silentCheckSsoFallback: true,
checkLoginIframe: true,
pkceMethod: 'S256'
});
passkeyUrl(path: string) {
return `${environment.keycloak.url}/realms/${encodeURIComponent(environment.keycloak.realm)}/passkey/${path}`;
}

get bearer() {
return this.keycloak.token;
async getChallenge() {
const res = await fetch(this.passkeyUrl('challenge'), {credentials: 'include'});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || 'Failed to get challenge');
return body.challenge;
}

/**
* Update access token if it is about to expire or has expired
* This is independent from the silent check sso or refresh token validity.
* @returns
*/
async updateToken() {
if (!this.keycloak.isTokenExpired(60)) {
return false;
}
try {
// Try to refresh token
return await this.keycloak.updateToken(60);
} catch (error) {
console.error('Failed to refresh token:', error);
// Redirect to login if refresh fails
await this.keycloak.login();
return false;
initAuth(): Promise<boolean> {
this._keycloak = new Keycloak(environment.keycloak);
return this._keycloak.init(this.checkSsoOptions);
}

updateToken(minValidity = 60): Promise<boolean> {
if (!this.keycloak.authenticated) {
return Promise.resolve(false);
}
return this.keycloak.updateToken(minValidity);
}

login(returnUrl?: string) {
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless:skip_if_exists' });
signInWithTum(returnUrl?: string) {
return this.keycloak.login({redirectUri: returnUrl || window.location.href});
}

logout() {
return this.keycloak.logout({ redirectUri: environment.redirect });
async registerPasskey() {
if (!this.keycloak.authenticated || !this.keycloak.token) {
throw new Error('User must be logged in first');
}

const challenge = await this.getChallenge();
const username = (this.keycloak.tokenParsed?.['preferred_username'] as string | undefined) || this.keycloak.tokenParsed?.sub || 'user';
const displayName = (this.keycloak.tokenParsed?.['name'] as string | undefined) || username;
const userIdBytes = new TextEncoder().encode(username).slice(0, 64);

const credential = await navigator.credentials.create({
publicKey: {
challenge: fromBase64Url(challenge),
rp: {name: 'Module Management', id: window.location.hostname},
user: {id: userIdBytes, name: username, displayName},
pubKeyCredParams: [{type: 'public-key', alg: -7}],
authenticatorSelection: {residentKey: 'required', userVerification: 'preferred'},
attestation: 'none'
}
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Failed to create public key credential');
}
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('Invalid attestation response');
}

const res = await fetch(this.passkeyUrl('save'), {
method: 'POST', credentials: 'include', headers: {
'Content-Type': 'application/json', Authorization: `Bearer ${this.keycloak.token}`
}, body: JSON.stringify({
credentialId: toBase64Url(credential.rawId),
rawId: toBase64Url(credential.rawId),
clientDataJSON: toBase64Url(credential.response.clientDataJSON),
attestationObject: toBase64Url(credential.response.attestationObject),
challenge
})
});

if (!res.ok) throw new Error(await res.text());
}

registerPasskey(returnUrl?: string) {
return this.keycloak.login({ redirectUri: window.location.origin + (returnUrl || ''), action: 'webauthn-register-passwordless' });
async signInWithPasskey() {
const challenge = await this.getChallenge();
const assertion = await navigator.credentials.get({
publicKey: {challenge: fromBase64Url(challenge), userVerification: 'preferred'}
});
if (!(assertion instanceof PublicKeyCredential)) {
throw new Error('Failed to create public key assertion');
}
if (!(assertion.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid assertion response');
}

const res = await fetch(this.passkeyUrl('authenticate'), {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json', Accept: 'application/json'},
body: JSON.stringify({
credentialId: toBase64Url(assertion.rawId),
rawId: toBase64Url(assertion.rawId),
clientDataJSON: toBase64Url(assertion.response.clientDataJSON),
authenticatorData: toBase64Url(assertion.response.authenticatorData),
signature: toBase64Url(assertion.response.signature),
challenge
})
});

if (res.status !== 204) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Passkey auth failed: ${res.status}`);
}

const authenticated = await this.initAuth(); // silent check-sso refresh
if (!authenticated) throw new Error('No session after passkey auth');
}

getCredentials() {
Expand All @@ -73,6 +147,10 @@ export class KeycloakService {

deleteCredential(credentialId: string) {
const url = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/account/credentials/${credentialId}`;
return this.http.delete<any[]>(url);
return this.http.delete<unknown>(url);
}

logout() {
return this.keycloak.logout({redirectUri: environment.redirect});
}
}
Loading
Loading