diff --git a/api/src/api_graphql/abc/mutation_abc.py b/api/src/api_graphql/abc/mutation_abc.py index 51ed410..e47dfa3 100644 --- a/api/src/api_graphql/abc/mutation_abc.py +++ b/api/src/api_graphql/abc/mutation_abc.py @@ -13,11 +13,11 @@ class MutationABC(QueryABC): QueryABC.__init__(self, f"{name}Mutation") def add_mutation_type( - self, - name: str, - mutation_name: str, - require_any_permission: list[Permissions] = None, - public: bool = False, + self, + name: str, + mutation_name: str, + require_any_permission=None, + public: bool = False, ): """ Add mutation type (sub mutation) to the mutation object @@ -27,6 +27,8 @@ class MutationABC(QueryABC): :param bool public: Define if the field can resolve without authentication :return: """ + if require_any_permission is None: + require_any_permission = [] from api_graphql.definition import QUERIES self.field( diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index cee4ca0..ad67439 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -7,4 +7,8 @@ type Mutation { group: GroupMutation domain: DomainMutation shortUrl: ShortUrlMutation + + setting: SettingMutation + userSetting: UserSettingMutation + featureFlag: FeatureFlagMutation } \ No newline at end of file diff --git a/api/src/api_graphql/input/feature_flag_input.py b/api/src/api_graphql/input/feature_flag_input.py new file mode 100644 index 0000000..8985caa --- /dev/null +++ b/api/src/api_graphql/input/feature_flag_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class FeatureFlagInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", bool, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> bool: + return self._value diff --git a/api/src/api_graphql/input/setting_input.py b/api/src/api_graphql/input/setting_input.py new file mode 100644 index 0000000..7354b57 --- /dev/null +++ b/api/src/api_graphql/input/setting_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class SettingInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", str, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value diff --git a/api/src/api_graphql/input/user_setting_input.py b/api/src/api_graphql/input/user_setting_input.py new file mode 100644 index 0000000..9d5fe09 --- /dev/null +++ b/api/src/api_graphql/input/user_setting_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class UserSettingInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._key = self.option("key", str, required=True) + self._value = self.option("value", str, required=True) + + @property + def key(self) -> str: + return self._key + + @property + def value(self) -> str: + return self._value diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index 78a0f88..d46df60 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -60,3 +60,22 @@ class Mutation(MutationABC): Permissions.short_urls_delete, ], ) + + self.add_mutation_type( + "setting", + "Setting", + require_any_permission=[ + Permissions.settings_update, + ], + ) + self.add_mutation_type( + "userSetting", + "UserSetting", + ) + self.add_mutation_type( + "featureFlag", + "FeatureFlag", + require_any_permission=[ + Permissions.administrator, + ], + ) diff --git a/api/src/api_graphql/mutations/feature_flag_mutation.py b/api/src/api_graphql/mutations/feature_flag_mutation.py new file mode 100644 index 0000000..c940619 --- /dev/null +++ b/api/src/api_graphql/mutations/feature_flag_mutation.py @@ -0,0 +1,32 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.feature_flag_input import FeatureFlagInput +from core.logger import APILogger +from data.schemas.system.feature_flag import FeatureFlag +from data.schemas.system.feature_flag_dao import featureFlagDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class FeatureFlagMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "FeatureFlag") + self.mutation( + "change", + self.resolve_change, + FeatureFlagInput, + require_any_permission=[Permissions.administrator], + ) + + @staticmethod + async def resolve_change(obj: FeatureFlagInput, *_): + logger.debug(f"create new feature flag: {input}") + + setting = await featureFlagDao.find_single_by({FeatureFlag.key: obj.key}) + if setting is None: + raise ValueError(f"FeatureFlag {obj.key} not found") + + setting.value = obj.value + await featureFlagDao.update(setting) + + return await featureFlagDao.get_by_id(setting.id) diff --git a/api/src/api_graphql/mutations/setting_mutation.py b/api/src/api_graphql/mutations/setting_mutation.py new file mode 100644 index 0000000..4c499f3 --- /dev/null +++ b/api/src/api_graphql/mutations/setting_mutation.py @@ -0,0 +1,32 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.setting_input import SettingInput +from core.logger import APILogger +from data.schemas.system.setting import Setting +from data.schemas.system.setting_dao import settingsDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class SettingMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "Setting") + self.mutation( + "change", + self.resolve_change, + SettingInput, + require_any_permission=[Permissions.settings_update], + ) + + @staticmethod + async def resolve_change(obj: SettingInput, *_): + logger.debug(f"create new setting: {input}") + + setting = await settingsDao.find_single_by({Setting.key: obj.key}) + if setting is None: + raise ValueError(f"Setting with key {obj.key} not found") + + setting.value = obj.value + await settingsDao.update(setting) + + return await settingsDao.get_by_id(setting.id) diff --git a/api/src/api_graphql/mutations/user_setting_mutation.py b/api/src/api_graphql/mutations/user_setting_mutation.py new file mode 100644 index 0000000..1fb012d --- /dev/null +++ b/api/src/api_graphql/mutations/user_setting_mutation.py @@ -0,0 +1,40 @@ +from api.route import Route +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.user_setting_input import UserSettingInput +from core.logger import APILogger +from data.schemas.public.user_setting import UserSetting +from data.schemas.public.user_setting_dao import userSettingsDao +from data.schemas.system.setting_dao import settingsDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class UserSettingMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "UserSetting") + self.mutation( + "change", + self.resolve_change, + UserSettingInput, + require_any_permission=[Permissions.settings_update], + ) + + @staticmethod + async def resolve_change(obj: UserSettingInput, *_): + logger.debug(f"create new setting: {input}") + user = await Route.get_user_or_default() + if user is None: + logger.debug("user not authorized") + return None + + setting = await userSettingsDao.find_single_by( + [{UserSetting.user_id: user.id}, {UserSetting.key: obj.key}] + ) + if setting is None: + await userSettingsDao.create(UserSetting(0, user.id, obj.key, obj.value)) + else: + setting.value = obj.value + await userSettingsDao.update(setting) + + return await userSettingsDao.find_by_key(user, obj.key) diff --git a/api/src/data/scripts/2025-03-08-08-15-user-settings.sql b/api/src/data/scripts/2025-03-08-08-15-user-settings.sql index 7a95dec..4c9dc5b 100644 --- a/api/src/data/scripts/2025-03-08-08-15-user-settings.sql +++ b/api/src/data/scripts/2025-03-08-08-15-user-settings.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS public.user_settings Id SERIAL PRIMARY KEY, Key TEXT NOT NULL, Value TEXT NOT NULL, - UserId INT NOT NULL REFERENCES public.user_settings (Id) ON DELETE CASCADE, + UserId INT NOT NULL REFERENCES administration.users (Id) ON DELETE CASCADE, -- for history Deleted BOOLEAN NOT NULL DEFAULT FALSE, EditorId INT NULL REFERENCES administration.users (Id), diff --git a/web/src/app/components/header/header.component.ts b/web/src/app/components/header/header.component.ts index 6ded32c..962dffa 100644 --- a/web/src/app/components/header/header.component.ts +++ b/web/src/app/components/header/header.component.ts @@ -8,8 +8,9 @@ import { User } from 'src/app/model/auth/user'; 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 { SettingsService } from 'src/app/service/settings.service'; import { ConfigService } from 'src/app/service/config.service'; +import { UserSettingsService } from 'src/app/service/user_settings.service'; +import { SettingsService } from 'src/app/service/settings.service'; @Component({ selector: 'app-header', @@ -33,7 +34,9 @@ export class HeaderComponent implements OnInit, OnDestroy { private guiService: GuiService, private auth: AuthService, private sidebarService: SidebarService, - private config: ConfigService + private config: ConfigService, + private settings: SettingsService, + private userSettings: UserSettingsService ) { this.guiService.isMobile$ .pipe(takeUntil(this.unsubscribe$)) @@ -47,13 +50,18 @@ export class HeaderComponent implements OnInit, OnDestroy { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { this.user = user; await this.initMenuLists(); + if (user) { + await this.loadTheme(); + await this.loadLang(); + } }); this.themeList = this.config.settings.themes.map(theme => { return { label: theme.label, - command: () => { + command: async () => { this.guiService.theme$.next(theme.name); + await this.userSettings.set('theme', theme.name); }, }; }); @@ -61,7 +69,6 @@ export class HeaderComponent implements OnInit, OnDestroy { async ngOnInit() { await this.initMenuLists(); - await this.loadLang(); } ngOnDestroy() { @@ -74,24 +81,49 @@ export class HeaderComponent implements OnInit, OnDestroy { } async initMenuLists() { + await this.initMenuList(); await this.initLangMenuList(); await this.initUserMenuList(); } + async initMenuList() { + this.menu = [ + { + label: 'common.news', + routerLink: ['/'], + icon: 'pi pi-home', + }, + { + label: 'header.menu.about', + routerLink: ['/about'], + icon: 'pi pi-info', + }, + ]; + + if (this.auth.user$.value) { + this.menu.push({ + label: 'header.menu.admin', + routerLink: ['/admin'], + icon: 'pi pi-cog', + visible: await this.auth.isAdmin(), + }); + } + } + async initLangMenuList() { this.langList = [ { label: 'English', - command: () => { + command: async () => { this.translate('en'); - this.setLang('en'); + await this.setLang('en'); }, }, { label: 'Deutsch', - command: () => { + command: async () => { this.translate('de'); - this.setLang('de'); + await this.setLang('de'); }, }, ]; @@ -109,10 +141,8 @@ export class HeaderComponent implements OnInit, OnDestroy { }, { label: this.translateService.instant('header.logout'), - command: () => { - this.auth.logout().then(() => { - console.log('logout'); - }); + command: async () => { + await this.auth.logout(); }, icon: 'pi pi-sign-out', }, @@ -126,15 +156,33 @@ export class HeaderComponent implements OnInit, OnDestroy { .subscribe(res => this.ngConfig.setTranslation(res)); } - async loadLang() { - const lang = 'en'; - this.setLang(lang); - this.translate(lang); + async loadTheme() { + const defaultTheme = (await this.settings.get('default_theme')) ?? 'maxlan'; + const userTheme = await this.userSettings.get('theme'); + const theme = userTheme ?? defaultTheme; + + this.guiService.theme$.next(theme); + + if (!userTheme) { + await this.userSettings.set('theme', theme); + } } - setLang(lang: string) { - // this.settings.setSetting(`lang`, lang); - console.log('setLang', lang); + async loadLang() { + const defaultLang = (await this.settings.get('default_language')) ?? 'en'; + const userLang = await this.userSettings.get('language'); + const lang = userLang ?? defaultLang; + + this.translate(lang); + + if (userLang) { + return; + } + await this.userSettings.set('language', lang); + } + + async setLang(lang: string) { + await this.userSettings.set('language', lang); } toggleSidebar() { diff --git a/web/src/app/service/auth.service.ts b/web/src/app/service/auth.service.ts index fe7943b..49cbae8 100644 --- a/web/src/app/service/auth.service.ts +++ b/web/src/app/service/auth.service.ts @@ -103,6 +103,14 @@ export class AuthService { return this.keycloakService.logout(); } + isLoggedIn(): boolean { + return ( + this.keycloakService.isLoggedIn() && + !this.keycloakService.isTokenExpired() && + !!this.user$.value + ); + } + async isAdmin(): Promise { return await this.hasAnyPermissionLazy(this.anyPermissionForAdminPage); } diff --git a/web/src/app/service/user_settings.service.ts b/web/src/app/service/user_settings.service.ts new file mode 100644 index 0000000..1cd0539 --- /dev/null +++ b/web/src/app/service/user_settings.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@angular/core'; +import { throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { Setting } from 'src/app/model/entities/setting'; +import { Apollo, gql } from 'apollo-angular'; +import { Logger } from 'src/app/service/logger.service'; +import { AuthService } from 'src/app/service/auth.service'; + +const log = new Logger('UserSettings'); + +@Injectable({ + providedIn: 'root', +}) +export class UserSettingsService { + constructor( + private apollo: Apollo, + private auth: AuthService + ) {} + + get(key: string): Promise { + if (!this.auth.isLoggedIn()) { + log.debug('get', key, 'not logged in'); + return Promise.resolve(undefined); + } + + log.debug('get', key); + return new Promise((resolve, reject) => { + this.apollo + .query<{ userSettings: Setting[] }>({ + query: gql` + query getUserSetting($key: String!) { + userSettings(key: $key) { + key + value + } + } + `, + variables: { + key, + }, + }) + .pipe( + catchError(err => { + throw err; + }) + ) + .pipe( + map(result => + result.data.userSettings?.length > 0 + ? result.data.userSettings[0] + : undefined + ) + ) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(setting => { + log.debug('Got', key, setting); + resolve(setting?.value); + }); + }); + } + + set(key: string, value: string): Promise { + if (!this.auth.isLoggedIn()) { + log.debug('set', key, value, 'not logged in'); + return Promise.resolve(undefined); + } + + log.debug('set', key, value); + return new Promise((resolve, reject) => { + this.apollo + .mutate<{ userSetting: { change: Setting } }>({ + mutation: gql` + mutation changeUserSetting($input: UserSettingInput!) { + userSetting { + change(input: $input) { + key + value + } + } + } + `, + variables: { + input: { + key: key, + value: value, + }, + }, + }) + .pipe( + catchError(err => { + throw err; + }) + ) + .pipe(map(result => result.data?.userSetting.change)) + .pipe( + catchError(err => { + reject(err); + return throwError(() => err); + }) + ) + .subscribe(() => { + resolve(); + }); + }); + } +} diff --git a/web/src/assets/i18n/de.json b/web/src/assets/i18n/de.json index 06861fd..4a3c0f9 100644 --- a/web/src/assets/i18n/de.json +++ b/web/src/assets/i18n/de.json @@ -16,7 +16,6 @@ "all": "Alle", "api_error": "API Fehler", "api_key": "API Key", - "by": "von", "cancel": "Abbrechen", "choose": "Auswählen", "close": "Schließen", @@ -28,7 +27,6 @@ "domain": "Domain", "domains": "Domains", "download": "Herunterladen", - "edited_at": "Bearbeitet am", "editor": "Bearbeiter", "error": "Fehler", "group": "Gruppe", @@ -37,7 +35,6 @@ "identifier": "Identifier", "key": "Schlüssel", "name": "Name", - "news": "News", "role": "Rolle", "save": "Speichern", "short_url": "Url", @@ -48,9 +45,7 @@ }, "dialog": { "abort": "Abbrechen", - "back": "Zurück", "confirm": "Bestätigen", - "continue": "Fortsetzen", "delete": { "header": "Löschen bestätigen", "message": "Sind Sie sicher, dass Sie {{entity}} löschen möchten?" @@ -58,22 +53,18 @@ "restore": { "header": "Wiederherstellung bestätigen", "message": "Sind Sie sicher, dass Sie {{entity}} wiederherstellen möchten?" - }, - "save": "Speichern" + } }, "domain": { "count_header": "Domain(s)" }, "error": { "404": "404 - Nicht gefunden", - "create_failed": "Erstellung fehlgeschlagen", - "delete_failed": "Löschen fehlgeschlagen", "missing_permissions": "Fehlende Berechtigungen: {{permissions}}", - "network_error": "Netzwerkfehler", "permission_denied": "Fehlende Berechtigung", - "restore_failed": "Wiederherstellung fehlgeschlagen", - "unauthorized": "Nicht autorisiert", - "update_failed": "Bearbeitung fehlgeschlagen" + "retry": "Erneut versuchen", + "server_unavailable": "Server nicht erreichbar", + "unauthorized": "Nicht autorisiert" }, "footer": { "imprint": "Impressum", @@ -84,11 +75,12 @@ "count_header": "Gruppe(n)" }, "header": { - "logout": "Ausloggen", - "menu": { - "about": "Über uns", - "admin": "Admin" - } + "logout": "Ausloggen" + }, + "permission_descriptions": { + "short_urls": "Alle URLs sehen", + "short_urls.by_assignment": "Alle Kurz-URLs anzeigen, die einer Gruppe nach Rolle zugewiesen sind", + "users.update": "Benutzer inkl. der Rollen ändern" }, "permissions": { "api_keys": "API Keys", @@ -107,10 +99,6 @@ "ip_list.create": "Erstellen", "ip_list.delete": "Löschen", "ip_list.update": "Bearbeiten", - "news": "News", - "news.create": "Erstellen", - "news.delete": "Löschen", - "news.update": "Bearbeiten", "roles": "Rollen", "roles.create": "Erstellen", "roles.delete": "Löschen", @@ -125,19 +113,10 @@ "users.delete": "Löschen", "users.update": "Bearbeiten" }, - "qr": { - "width": "Breite" - }, - "permission_descriptions": { - "users.update": "Benutzer inkl. der Rollen ändern", - "short_urls": "Alle URLs sehen", - "short_urls.by_assignment": "Alle Kurz-URLs anzeigen, die einer Gruppe nach Rolle zugewiesen sind" - }, "role": { "count_header": "Rolle(n)" }, "short_url": { - "count_header": "Url(s)", "loading_screen": "Ladebildschirm", "short_url": "URL", "target_url": "Ziel", @@ -146,12 +125,13 @@ "sidebar": { "administration": "Administration", "api_keys": "API Keys", + "feature_flags": "Funktionen", "header": { "create": "erstellen", "update": "bearbeiten" }, - "public": "Öffentlich", "roles": "Rollen", + "settings": "Einstellungen", "users": "Benutzer" }, "table": { @@ -163,6 +143,7 @@ "reset_filters": "Filter zurücksetzen", "reset_sort": "Sortierung zurücksetzen", "restore": "Wiederherstellen", + "select_columns": "Spalten auswählen", "show_deleted": "Gelöschte anzeigen", "to": "bis", "update": "Bearbeiten" diff --git a/web/src/assets/i18n/en.json b/web/src/assets/i18n/en.json index 75129b7..1f20718 100644 --- a/web/src/assets/i18n/en.json +++ b/web/src/assets/i18n/en.json @@ -16,7 +16,6 @@ "all": "All", "api_error": "API error", "api_key": "API Key", - "by": "by", "cancel": "Cancel", "choose": "Choose", "close": "Close", @@ -28,7 +27,6 @@ "domain": "Domain", "domains": "Domains", "download": "Download", - "edited_at": "Edited at", "editor": "Editor", "error": "Fehler", "group": "Group", @@ -37,7 +35,6 @@ "identifier": "Identifier", "key": "Key", "name": "Name", - "news": "News", "role": "Role", "save": "Save", "short_url": "Url", @@ -48,9 +45,7 @@ }, "dialog": { "abort": "Abort", - "back": "Back", "confirm": "Confirm", - "continue": "Continue", "delete": { "header": "Confirm delete", "message": "Are you sure you want to delete {{entity}}?" @@ -58,22 +53,18 @@ "restore": { "header": "Confirm restore", "message": "Are you sure you want to restore {{entity}}?" - }, - "save": "Save" + } }, "domain": { "count_header": "Domain(s)" }, "error": { "404": "404 - Not found", - "create_failed": "Create failed", - "delete_failed": "Delete failed", "missing_permissions": "Missing permissions: {{permissions}}", - "network_error": "Network Error", "permission_denied": "Permission Denied", - "restore_failed": "Restore failed", - "unauthorized": "Unauthorized", - "update_failed": "Update failed" + "retry": "Retry", + "server_unavailable": "Server unavailable", + "unauthorized": "Unauthorized" }, "footer": { "imprint": "Imprint", @@ -84,11 +75,12 @@ "count_header": "Group(s)" }, "header": { - "logout": "Logout", - "menu": { - "about": "About", - "admin": "Admin" - } + "logout": "Logout" + }, + "permission_descriptions": { + "short_urls": "See all URLs", + "short_urls.by_assignment": "See all short urls assigned to a group by role", + "users.update": "Change users including their roles" }, "permissions": { "api_keys": "API Keys", @@ -107,10 +99,6 @@ "ip_list.create": "Create", "ip_list.delete": "Delete", "ip_list.update": "Update", - "news": "News", - "news.create": "Create", - "news.delete": "Delete", - "news.update": "Update", "roles": "Roles", "roles.create": "Create", "roles.delete": "Delete", @@ -125,19 +113,10 @@ "users.delete": "Delete", "users.update": "Update" }, - "permission_descriptions": { - "users.update": "Change users including their roles", - "short_urls": "See all URLs", - "short_urls.by_assignment": "See all short urls assigned to a group by role" - }, - "qr": { - "width": "Width" - }, "role": { "count_header": "Role(s)" }, "short_url": { - "count_header": "Url(s)", "loading_screen": "Loading screen", "short_url": "URL", "target_url": "Target", @@ -146,12 +125,13 @@ "sidebar": { "administration": "Administration", "api_keys": "API Keys", + "feature_flags": "Features", "header": { "create": "create", "update": "update" }, - "public": "Public", "roles": "Roles", + "settings": "Settings", "users": "Users" }, "table": { @@ -163,6 +143,7 @@ "reset_filters": "Reset filters", "reset_sort": "Reset sort", "restore": "Restore", + "select_columns": "Select columns", "show_deleted": "Show deleted", "to": "to", "update": "Update"