diff --git a/api/src/api/route_user_extension.py b/api/src/api/route_user_extension.py
index 9efee96..585390d 100644
--- a/api/src/api/route_user_extension.py
+++ b/api/src/api/route_user_extension.py
@@ -53,7 +53,7 @@ class RouteUserExtension:
user_id = cls._get_user_id_from_token(request)
if not user_id:
- return await cls.get_dev_user()
+ return None
return await userDao.find_by_keycloak_id(user_id)
diff --git a/api/src/api_graphql/graphql/privacy.gql b/api/src/api_graphql/graphql/privacy.gql
index 0a1410c..b1f6960 100644
--- a/api/src/api_graphql/graphql/privacy.gql
+++ b/api/src/api_graphql/graphql/privacy.gql
@@ -1,5 +1,5 @@
type PrivacyMutation {
exportData(userId: Int!): String
- anonymizeData(userId: Int!): String
- deleteData(userId: Int!): String
+ anonymizeData(userId: Int!): Boolean
+ deleteData(userId: Int!): Boolean
}
\ No newline at end of file
diff --git a/api/src/core/database/abc/data_access_object_abc.py b/api/src/core/database/abc/data_access_object_abc.py
index 4fe4874..a3003db 100644
--- a/api/src/core/database/abc/data_access_object_abc.py
+++ b/api/src/core/database/abc/data_access_object_abc.py
@@ -179,6 +179,14 @@ class DataAccessObjectABC(ABC, Database, Generic[T_DBM]):
value_map[attr_name] = value
+ for ex_fname in self._external_fields:
+ ex_field = self._external_fields[ex_fname]
+ for ex_attr in ex_field.fields:
+ if ex_attr == self.__primary_key:
+ continue
+
+ value_map[ex_attr] = getattr(obj, ex_attr, None)
+
return value_map
async def count(self, filters: AttributeFilters = None) -> int:
diff --git a/api/src/data/schemas/administration/user.py b/api/src/data/schemas/administration/user.py
index 3a44626..b17738b 100644
--- a/api/src/data/schemas/administration/user.py
+++ b/api/src/data/schemas/administration/user.py
@@ -1,3 +1,4 @@
+import uuid
from datetime import datetime
from typing import Optional
@@ -28,10 +29,16 @@ class User(DbModelABC):
@property
def username(self):
+ if self._keycloak_id == str(uuid.UUID(int=0)):
+ return "ANONYMOUS"
+
return Keycloak.admin.get_user(self._keycloak_id).get("username")
@property
def email(self):
+ if self._keycloak_id == str(uuid.UUID(int=0)):
+ return "ANONYMOUS"
+
return Keycloak.admin.get_user(self._keycloak_id).get("email")
@async_property
@@ -50,3 +57,9 @@ class User(DbModelABC):
from data.schemas.administration.user_dao import userDao
return await userDao.has_permission(self.id, permission)
+
+ async def anonymize(self):
+ from data.schemas.administration.user_dao import userDao
+
+ self._keycloak_id = str(uuid.UUID(int=0))
+ await userDao.update(self)
diff --git a/api/src/data/schemas/administration/user_dao.py b/api/src/data/schemas/administration/user_dao.py
index 7759dd3..4649708 100644
--- a/api/src/data/schemas/administration/user_dao.py
+++ b/api/src/data/schemas/administration/user_dao.py
@@ -1,6 +1,7 @@
from typing import Optional, Union
from core.database.abc.db_model_dao_abc import DbModelDaoABC
+from core.database.external_data_temp_table_builder import ExternalDataTempTableBuilder
from core.logger import DBLogger
from data.schemas.administration.user import User
from data.schemas.permission.permission_dao import permissionDao
@@ -14,7 +15,19 @@ class UserDao(DbModelDaoABC[User]):
def __init__(self):
DbModelDaoABC.__init__(self, __name__, User, "administration.users")
- self.attribute(User.keycloak_id, str)
+ self.attribute(User.keycloak_id, str, aliases=["keycloakId"])
+
+ async def get_users():
+ return [(x.id, x.username, x.email) for x in await self.get_all()]
+
+ self.use_external_fields(
+ ExternalDataTempTableBuilder()
+ .with_table_name(self._table_name)
+ .with_field("id", "int", True)
+ .with_field("username", "text")
+ .with_field("email", "text")
+ .with_value_getter(get_users)
+ )
async def get_by_keycloak_id(self, keycloak_id: str) -> User:
return await self.get_single_by({User.keycloak_id: keycloak_id})
diff --git a/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql b/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql
new file mode 100644
index 0000000..8c77c31
--- /dev/null
+++ b/api/src/data/scripts/2025-04-30-15-30-keycloak-id-anonymizable.sql
@@ -0,0 +1,7 @@
+-- Drop the existing unique constraint
+ALTER TABLE administration.users DROP CONSTRAINT IF EXISTS UC_KeycloakId;
+
+-- Add a partial unique index to allow guid-0 duplicates
+CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_keycloakid
+ ON administration.users (KeycloakId)
+ WHERE KeycloakId != '00000000-0000-0000-0000-000000000000';
\ No newline at end of file
diff --git a/api/src/service/data_privacy_service.py b/api/src/service/data_privacy_service.py
index 974f0d6..e0d6d82 100644
--- a/api/src/service/data_privacy_service.py
+++ b/api/src/service/data_privacy_service.py
@@ -3,6 +3,7 @@ import json
from typing import Type
from api.auth.keycloak_client import Keycloak
+from api.broadcast import broadcast
from core.database.abc.data_access_object_abc import DataAccessObjectABC
from core.database.abc.db_model_dao_abc import DbModelDaoABC
from core.logger import Logger
@@ -52,12 +53,14 @@ class DataPrivacyService:
if user is None:
raise ValueError("User not found")
- collected_data = [userDao.to_dict(await userDao.find_by_id(user_id))]
+ collected_data = {"user": userDao.to_dict(await userDao.find_by_id(user_id))}
daos = await cls._collect_user_relevant_dao()
for dao in daos:
data = await dao.find_by([{"userid": user_id}])
- collected_data.append([dao.to_dict(x) for x in data])
+ collected_data[first_to_lower(type(dao).__name__.replace("Dao", "s"))] = [
+ dao.to_dict(x) for x in data
+ ]
return json.dumps(collected_data, default=str)
@@ -74,16 +77,18 @@ class DataPrivacyService:
keycloak_id = user.keycloak_id
# Anonymize internal data
- user.keycloak_id = "ANONYMIZED"
- userDao.update(user)
+ await user.anonymize()
# Anonymize external data
try:
Keycloak.admin.delete_user(keycloak_id)
+ await broadcast.publish("userLogout", user.id)
except Exception as e:
logger.error(f"Failed to anonymize external data for user {user_id}", e)
raise ValueError("Failed to anonymize external data") from e
+ return True
+
@classmethod
async def delete_user_data(cls, user_id: int):
"""
@@ -117,3 +122,5 @@ class DataPrivacyService:
except Exception as e:
logger.error(f"Failed to delete external data for user {user_id}", e)
raise ValueError("Failed to delete external data") from e
+
+ return True
diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html
index b3cf78b..ea278c4 100644
--- a/web/src/app/app.component.html
+++ b/web/src/app/app.component.html
@@ -17,7 +17,7 @@
-
+
preloader.preloadLanguages();
+}
+
export function HttpLoaderFactory(http: HttpClient) {
- return new TranslateHttpLoader(http);
+ return new TranslateHttpLoader(http, '/assets/i18n/', '.json');
}
export function appInitializerFactory(
@@ -89,6 +94,12 @@ export function appInitializerFactory(
AppRoutingModule,
],
providers: [
+ {
+ provide: APP_INITIALIZER,
+ useFactory: preloadTranslations,
+ deps: [TranslationPreloaderService],
+ multi: true,
+ },
MessageService,
ConfirmationService,
DialogService,
diff --git a/web/src/app/components/header/header.component.ts b/web/src/app/components/header/header.component.ts
index 6a0a3b0..d1055f3 100644
--- a/web/src/app/components/header/header.component.ts
+++ b/web/src/app/components/header/header.component.ts
@@ -1,5 +1,5 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
-import { MenuItem, MenuItemCommandEvent, PrimeNGConfig } from 'primeng/api';
+import { Component, EventEmitter, OnDestroy, OnInit } from '@angular/core';
+import { MenuItem, PrimeNGConfig } from 'primeng/api';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { TranslateService } from '@ngx-translate/core';
@@ -9,9 +9,13 @@ import { AuthService } from 'src/app/service/auth.service';
import { MenuElement } from 'src/app/model/view/menu-element';
import { SidebarService } from 'src/app/service/sidebar.service';
import { ConfigService } from 'src/app/service/config.service';
-import { UserSettingsService } from 'src/app/service/user_settings.service';
+import { UserSettingsService } from 'src/app/service/user-settings.service';
import { SettingsService } from 'src/app/service/settings.service';
-import { environment } from 'src/environments/environment';
+import { DataPrivacyService } from 'src/app/service/data-privacy.service';
+import { ConfirmationDialogService } from 'src/app/service/confirmation-dialog.service';
+import { Logger } from 'src/app/service/logger.service';
+
+const logger = new Logger('Header');
@Component({
selector: 'app-header',
@@ -29,6 +33,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
menu: MenuElement[] = [];
+ private langChange: EventEmitter = new EventEmitter();
+
constructor(
private translateService: TranslateService,
private ngConfig: PrimeNGConfig,
@@ -37,8 +43,14 @@ export class HeaderComponent implements OnInit, OnDestroy {
private sidebarService: SidebarService,
private config: ConfigService,
private settings: SettingsService,
- private userSettings: UserSettingsService
+ private userSettings: UserSettingsService,
+ private dataPrivacyService: DataPrivacyService,
+ private confirmation: ConfirmationDialogService
) {
+ this.langChange.subscribe(async () => {
+ await this.initUserMenuList();
+ });
+
this.guiService.isMobile$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(isMobile => {
@@ -51,7 +63,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
this.user = user;
- await this.initMenuLists();
await this.loadTheme();
await this.loadLang();
@@ -123,12 +134,31 @@ export class HeaderComponent implements OnInit, OnDestroy {
items: [
{
label: this.translateService.instant('privacy.export_data'),
- command: () => {},
+ command: () => {
+ if (!this.user) {
+ return;
+ }
+ this.dataPrivacyService.downloadDataExportJson(this.user?.id);
+ },
icon: 'pi pi-download',
},
{
label: this.translateService.instant('privacy.delete_data'),
- command: () => {},
+ command: () => {
+ this.confirmation.confirmDialog({
+ header: 'privacy.delete_data_header',
+ message: 'privacy.delete_data_message',
+ accept: () => {
+ if (!this.user) {
+ return;
+ }
+
+ this.dataPrivacyService
+ .anonymizeData(this.user.id)
+ .subscribe(() => {});
+ },
+ });
+ },
icon: 'pi pi-trash',
},
],
@@ -159,10 +189,12 @@ export class HeaderComponent implements OnInit, OnDestroy {
}
translate(lang: string) {
+ logger.debug('translate', lang);
this.translateService.use(lang);
this.translateService
.get('primeng')
.subscribe(res => this.ngConfig.setTranslation(res));
+ this.langChange.next();
}
async loadTheme() {
diff --git a/web/src/app/model/config/app-settings.ts b/web/src/app/model/config/app-settings.ts
index 690d6be..3aeb1a9 100644
--- a/web/src/app/model/config/app-settings.ts
+++ b/web/src/app/model/config/app-settings.ts
@@ -1,5 +1,16 @@
import { Theme } from 'src/app/model/view/theme';
+export interface AppSettingsFromConfig {
+ termsUrl: string;
+ privacyURL: string;
+ imprintURL: string;
+ themes: Theme[];
+ loadingScreen: LoadingScreenSettings;
+ keycloak: KeycloakSettings;
+ api: ApiSettings;
+ languages?: string[];
+}
+
export interface AppSettings {
termsUrl: string;
privacyURL: string;
@@ -8,6 +19,7 @@ export interface AppSettings {
loadingScreen: LoadingScreenSettings;
keycloak: KeycloakSettings;
api: ApiSettings;
+ languages: string[];
}
export interface LoadingScreenSettings {
diff --git a/web/src/app/service/config.service.ts b/web/src/app/service/config.service.ts
index 2a853ed..8d67a54 100644
--- a/web/src/app/service/config.service.ts
+++ b/web/src/app/service/config.service.ts
@@ -1,7 +1,10 @@
import { Injectable } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
-import { AppSettings } from 'src/app/model/config/app-settings';
+import {
+ AppSettings,
+ AppSettingsFromConfig,
+} from 'src/app/model/config/app-settings';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
@@ -33,6 +36,7 @@ export class ConfigService {
realm: '',
clientId: '',
},
+ languages: ['en', 'de'],
};
constructor(private http: HttpClient) {}
@@ -40,7 +44,7 @@ export class ConfigService {
loadSettings(): Promise {
return new Promise((resolve, reject) => {
this.http
- .get(`/assets/config/${environment.config}`)
+ .get(`/assets/config/${environment.config}`)
.pipe(
catchError(error => {
reject(error);
@@ -48,13 +52,17 @@ export class ConfigService {
})
)
.subscribe(settings => {
- this.settings = settings;
- if (this.settings.themes.length === 0) {
- this.settings.themes.push({
+ if (settings.themes.length === 0) {
+ settings.themes.push({
label: 'Maxlan',
name: 'maxlan',
});
}
+ if (settings.languages?.length === 0) {
+ settings.languages = ['en', 'de'];
+ }
+
+ this.settings = settings as AppSettings;
resolve();
});
});
diff --git a/web/src/app/service/data-privacy.service.ts b/web/src/app/service/data-privacy.service.ts
new file mode 100644
index 0000000..5b8ab8b
--- /dev/null
+++ b/web/src/app/service/data-privacy.service.ts
@@ -0,0 +1,77 @@
+import { Injectable } from '@angular/core';
+import { Apollo, gql } from 'apollo-angular';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class DataPrivacyService {
+ constructor(private apollo: Apollo) {}
+
+ downloadDataExportJson(userId: number): void {
+ this.exportDataPrivacy(userId).subscribe(dataString => {
+ const jsonData = JSON.stringify(JSON.parse(dataString), null, 2); // Format nicely
+ const blob = new Blob([jsonData], { type: 'application/json' });
+ const url = window.URL.createObjectURL(blob);
+
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = 'exported-data.json';
+ anchor.click();
+
+ window.URL.revokeObjectURL(url);
+ });
+ }
+
+ exportDataPrivacy(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { exportData: string } }>({
+ mutation: gql`
+ mutation exportDataPrivacy($userId: Int!) {
+ privacy {
+ exportData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.exportData ?? ''));
+ }
+
+ anonymizeData(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { anonymizeData: boolean } }>({
+ mutation: gql`
+ mutation anonymizeDataPrivacy($userId: Int!) {
+ privacy {
+ anonymizeData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.anonymizeData ?? false));
+ }
+
+ deleteData(userId: number): Observable {
+ return this.apollo
+ .mutate<{ privacy: { deleteData: boolean } }>({
+ mutation: gql`
+ mutation deleteDataPrivacy($userId: Int!) {
+ privacy {
+ deleteData(userId: $userId)
+ }
+ }
+ `,
+ variables: {
+ userId,
+ },
+ })
+ .pipe(map(result => result.data?.privacy.deleteData ?? false));
+ }
+}
diff --git a/web/src/app/service/translation-preloader.service.ts b/web/src/app/service/translation-preloader.service.ts
new file mode 100644
index 0000000..be74ea4
--- /dev/null
+++ b/web/src/app/service/translation-preloader.service.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { HttpClient } from '@angular/common/http';
+import { lastValueFrom } from 'rxjs';
+import { ConfigService } from 'src/app/service/config.service';
+
+@Injectable({ providedIn: 'root' })
+export class TranslationPreloaderService {
+ constructor(
+ private translate: TranslateService,
+ private http: HttpClient,
+ private config: ConfigService
+ ) {}
+
+ preloadLanguages(): Promise {
+ const loadRequests = this.config.settings.languages.map(async lang => {
+ const translations = await lastValueFrom(
+ this.http.get(`/assets/i18n/${lang}.json`)
+ );
+ this.translate.setTranslation(lang, translations, true); // merge = true
+ });
+
+ return Promise.all(loadRequests).then(() => {});
+ }
+}
diff --git a/web/src/app/service/user_settings.service.ts b/web/src/app/service/user-settings.service.ts
similarity index 100%
rename from web/src/app/service/user_settings.service.ts
rename to web/src/app/service/user-settings.service.ts
diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json
index 839c5ad..aa96be0 100644
--- a/web/src/assets/i18n/de.json
+++ b/web/src/assets/i18n/de.json
@@ -76,7 +76,10 @@
"count_header": "Gruppe(n)"
},
"header": {
- "logout": "Ausloggen"
+ "edit_profile": "Profil bearbeiten",
+ "logout": "Ausloggen",
+ "privacy": "Datenschutz",
+ "profile": "Profil"
},
"history": {
"header": "Historie"
@@ -211,6 +214,12 @@
"weak": "Woche",
"weekHeader": "Wk"
},
+ "privacy": {
+ "delete_data": "Daten löschen",
+ "delete_data_header": "Bestätigung zur Löschung deiner personenbezogenen Daten",
+ "delete_data_message": "Bestätigung zur Löschung deiner personenbezogenen Daten
Du bist dabei, dein Konto dauerhaft zu löschen.
Damit werden deine personenbezogenen Daten unkenntlich gemacht.
Eine Wiederherstellung deines Kontos oder deiner Daten ist anschließend nicht mehr möglich.
Hinweis:
Aus rechtlichen oder betrieblichen Gründen (z. B. gesetzliche Aufbewahrungspflichten) können bestimmte Daten weiterhin in anonymisierter oder pseudonymisierter Form gespeichert bleiben. Diese enthalten keine Informationen mehr, die dich als Person identifizierbar machen.
Bitte bestätige, dass du dies verstanden hast und mit der Löschung fortfahren möchtest.",
+ "export_data": "Daten exportieren"
+ },
"role": {
"count_header": "Rolle(n)"
},
diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json
index bc91320..4f95c94 100644
--- a/web/src/assets/i18n/en.json
+++ b/web/src/assets/i18n/en.json
@@ -76,7 +76,10 @@
"count_header": "Group(s)"
},
"header": {
- "logout": "Logout"
+ "edit_profile": "Edit profile",
+ "logout": "Logout",
+ "privacy": "Privacy",
+ "profile": "Profile"
},
"history": {
"header": "History"
@@ -211,6 +214,12 @@
"weak": "Weak",
"weekHeader": "Wk"
},
+ "privacy": {
+ "delete_data": "Delete data",
+ "delete_data_header": "Confirmation of deletion of your personal data",
+ "delete_data_message": "Confirmation of deletion of your personal data
You are about to permanently delete your account.
Your personal data will be anonymized and cannot be recovered.
It will no longer be possible to restore your account or associated data.
Note:
For legal or operational reasons (e.g. statutory retention obligations), certain data may continue to be stored in anonymized or pseudonymized form. This data no longer contains any information that can identify you as a person.
Please confirm that you understand this and wish to proceed with the deletion.",
+ "export_data": "Export data"
+ },
"role": {
"count_header": "Role(s)"
},