Updated history and frontend

This commit is contained in:
Sven Heidemann 2025-04-18 10:46:06 +02:00
parent 347f8486af
commit 2fae856412
123 changed files with 2263 additions and 1048 deletions

View File

@ -8,32 +8,16 @@ from starlette.routing import Route as StarletteRoute
from api.errors import unauthorized from api.errors import unauthorized
from api.middleware.request import get_request from api.middleware.request import get_request
from api.route_api_key_extension import RouteApiKeyExtension
from api.route_user_extension import RouteUserExtension from api.route_user_extension import RouteUserExtension
from core.environment import Environment from core.environment import Environment
from data.schemas.administration.api_key import ApiKey from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao
from data.schemas.administration.user import User from data.schemas.administration.user import User
class Route(RouteUserExtension): class Route(RouteUserExtension, RouteApiKeyExtension):
registered_routes: list[StarletteRoute] = [] registered_routes: list[StarletteRoute] = []
@classmethod
async def get_api_key(cls, request: Request) -> ApiKey:
auth_header = request.headers.get("Authorization", None)
api_key = auth_header.split(" ")[1]
return await apiKeyDao.find_by_key(api_key)
@classmethod
async def _verify_api_key(cls, req: Request) -> bool:
auth_header = req.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("API-Key "):
return False
api_key = auth_header.split(" ")[1]
api_key_from_db = await apiKeyDao.find_by_key(api_key)
return api_key_from_db is not None and not api_key_from_db.deleted
@classmethod @classmethod
async def _get_auth_type( async def _get_auth_type(
cls, request: Request, auth_header: str cls, request: Request, auth_header: str
@ -79,8 +63,7 @@ class Route(RouteUserExtension):
return await cls._get_auth_type(request, auth_header) return await cls._get_auth_type(request, auth_header)
@classmethod @classmethod
async def is_authorized(cls) -> bool: async def is_authorized(cls, request: Request) -> bool:
request = get_request()
if request is None: if request is None:
return False return False
@ -119,7 +102,7 @@ class Route(RouteUserExtension):
return await f(request, *args, **kwargs) return await f(request, *args, **kwargs)
return f(request, *args, **kwargs) return f(request, *args, **kwargs)
if not await cls.is_authorized(): if not await cls.is_authorized(request):
return unauthorized() return unauthorized()
if iscoroutinefunction(f): if iscoroutinefunction(f):

View File

@ -0,0 +1,27 @@
from starlette.requests import Request
from data.schemas.administration.api_key import ApiKey
from data.schemas.administration.api_key_dao import apiKeyDao
class RouteApiKeyExtension:
@classmethod
async def get_api_key(cls, request: Request) -> ApiKey:
auth_header = request.headers.get("Authorization", None)
api_key = auth_header.split(" ")[1]
return await apiKeyDao.find_single_by(
[{ApiKey.key: api_key}, {ApiKey.deleted: False}]
)
@classmethod
async def _verify_api_key(cls, req: Request) -> bool:
auth_header = req.headers.get("Authorization", None)
if not auth_header or not auth_header.startswith("API-Key "):
return False
api_key = auth_header.split(" ")[1]
api_key_from_db = await apiKeyDao.find_single_by(
[{ApiKey.key: api_key}, {ApiKey.deleted: False}]
)
return api_key_from_db is not None and not api_key_from_db.deleted

View File

@ -18,6 +18,7 @@ logger = Logger(__name__)
class RouteUserExtension: class RouteUserExtension:
_cached_users: dict[int, User] = {}
@classmethod @classmethod
def _get_user_id_from_token(cls, request: Request) -> Optional[str]: def _get_user_id_from_token(cls, request: Request) -> Optional[str]:
@ -62,9 +63,7 @@ class RouteUserExtension:
if request is None: if request is None:
return None return None
return await userDao.find_single_by( return await userDao.find_by_keycloak_id(cls.get_token(request))
[{User.keycloak_id: cls.get_token(request)}, {User.deleted: False}]
)
@classmethod @classmethod
async def get_user_or_default(cls) -> Optional[User]: async def get_user_or_default(cls) -> Optional[User]:

View File

@ -7,8 +7,7 @@ from core.database.abc.data_access_object_abc import DataAccessObjectABC
class DbHistoryModelQueryABC(QueryABC): class DbHistoryModelQueryABC(QueryABC):
def __init__(self, name: str = None): def __init__(self, name: str = __name__):
assert name is not None, f"Name for {__name__} must be provided"
QueryABC.__init__(self, f"{name}History") QueryABC.__init__(self, f"{name}History")
self.set_field("id", lambda x, *_: x.id) self.set_field("id", lambda x, *_: x.id)

View File

@ -4,6 +4,18 @@ type DomainResult {
nodes: [Domain] nodes: [Domain]
} }
type DomainHistory implements DbHistoryModel {
id: Int
name: String
shortUrls: [ShortUrl]
deleted: Boolean
editor: String
created: String
updated: String
}
type Domain implements DbModel { type Domain implements DbModel {
id: Int id: Int
name: String name: String
@ -14,6 +26,8 @@ type Domain implements DbModel {
editor: User editor: User
created: String created: String
updated: String updated: String
history: [DomainHistory]
} }
input DomainSort { input DomainSort {

View File

@ -4,6 +4,19 @@ type GroupResult {
nodes: [Group] nodes: [Group]
} }
type GroupHistory implements DbHistoryModel {
id: Int
name: String
shortUrls: [ShortUrl]
roles: [Role]
deleted: Boolean
editor: String
created: String
updated: String
}
type Group implements DbModel { type Group implements DbModel {
id: Int id: Int
name: String name: String
@ -15,6 +28,7 @@ type Group implements DbModel {
editor: User editor: User
created: String created: String
updated: String updated: String
history: [GroupHistory]
} }
input GroupSort { input GroupSort {

View File

@ -4,6 +4,17 @@ type PermissionResult {
nodes: [Permission] nodes: [Permission]
} }
type PermissionHistory implements DbHistoryModel {
id: Int
name: String
description: String
deleted: Boolean
editor: String
created: String
updated: String
}
type Permission implements DbModel { type Permission implements DbModel {
id: Int id: Int
name: String name: String
@ -13,6 +24,8 @@ type Permission implements DbModel {
editor: User editor: User
created: String created: String
updated: String updated: String
history: [PermissionHistory]
} }
input PermissionSort { input PermissionSort {
@ -21,7 +34,7 @@ input PermissionSort {
description: SortOrder description: SortOrder
deleted: SortOrder deleted: SortOrder
editorId: SortOrder editor: UserSort
created: SortOrder created: SortOrder
updated: SortOrder updated: SortOrder
} }
@ -32,7 +45,7 @@ input PermissionFilter {
description: StringFilter description: StringFilter
deleted: BooleanFilter deleted: BooleanFilter
editor: IntFilter editor: UserFilter
created: DateFilter created: DateFilter
updated: DateFilter updated: DateFilter
} }

View File

@ -4,6 +4,23 @@ type ShortUrlResult {
nodes: [ShortUrl] nodes: [ShortUrl]
} }
type ShortUrlHistory implements DbHistoryModel {
id: Int
shortUrl: String
targetUrl: String
description: String
visits: Int
loadingScreen: Boolean
group: Group
domain: Domain
deleted: Boolean
editor: String
created: String
updated: String
}
type ShortUrl implements DbModel { type ShortUrl implements DbModel {
id: Int id: Int
shortUrl: String shortUrl: String
@ -18,6 +35,7 @@ type ShortUrl implements DbModel {
editor: User editor: User
created: String created: String
updated: String updated: String
history: [ShortUrlHistory]
} }
input ShortUrlSort { input ShortUrlSort {

View File

@ -9,6 +9,7 @@ type Subscription {
settingChange: SubscriptionChange settingChange: SubscriptionChange
userChange: SubscriptionChange userChange: SubscriptionChange
userSettingChange: SubscriptionChange userSettingChange: SubscriptionChange
userLogout: SubscriptionChange
domainChange: SubscriptionChange domainChange: SubscriptionChange
groupChange: SubscriptionChange groupChange: SubscriptionChange

View File

@ -0,0 +1,22 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from data.schemas.public.domain import Domain
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
class DomainHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "Domain")
self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls)
@staticmethod
async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by(
[
{ShortUrl.domain_id: domain.id},
{ShortUrl.deleted: False},
{"updated": {"lessOrEqual": domain.updated}},
]
)

View File

@ -1,5 +1,6 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.domain import Domain from data.schemas.public.domain import Domain
from data.schemas.public.domain_dao import domainDao
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
@ -7,11 +8,13 @@ from data.schemas.public.short_url_dao import shortUrlDao
class DomainQuery(DbModelQueryABC): class DomainQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "Domain") DbModelQueryABC.__init__(self, "Domain", domainDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.set_field("shortUrls", self._get_urls) self.set_field("shortUrls", self._get_urls)
self.set_history_reference_dao(shortUrlDao, "domainid")
@staticmethod @staticmethod
async def _get_urls(domain: Domain, *_): async def _get_urls(domain: Domain, *_):
return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id}) return await shortUrlDao.find_by({ShortUrl.domain_id: domain.id})

View File

@ -0,0 +1,47 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao
from service.permission.permissions_enum import Permissions
class GroupHistoryQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "Group")
self.set_field("name", lambda x, *_: x.name)
self.field(
ResolverFieldBuilder("shortUrls")
.with_resolver(self._get_urls)
.with_require_any(
[
Permissions.groups,
],
[group_by_assignment_resolver],
)
)
self.set_field(
"roles",
lambda x, *_: self._resolve_foreign_history(
x.updated,
x.id,
groupRoleAssignmentDao,
groupDao,
lambda y: y.role_id,
obj_key="groupid",
),
)
@staticmethod
async def _get_urls(group: Group, *_):
return await shortUrlDao.find_by(
[
{ShortUrl.group_id: group.id},
{ShortUrl.deleted: False},
{"updated": {"lessOrEqual": group.updated}},
]
)

View File

@ -3,6 +3,7 @@ from api_graphql.field.resolver_field_builder import ResolverFieldBuilder
from api_graphql.require_any_resolvers import group_by_assignment_resolver from api_graphql.require_any_resolvers import group_by_assignment_resolver
from data.schemas.public.group import Group from data.schemas.public.group import Group
from data.schemas.public.group_dao import groupDao from data.schemas.public.group_dao import groupDao
from data.schemas.public.group_role_assignment_dao import groupRoleAssignmentDao
from data.schemas.public.short_url import ShortUrl from data.schemas.public.short_url import ShortUrl
from data.schemas.public.short_url_dao import shortUrlDao from data.schemas.public.short_url_dao import shortUrlDao
from service.permission.permissions_enum import Permissions from service.permission.permissions_enum import Permissions
@ -10,7 +11,7 @@ from service.permission.permissions_enum import Permissions
class GroupQuery(DbModelQueryABC): class GroupQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "Group") DbModelQueryABC.__init__(self, "Group", groupDao, with_history=True)
self.set_field("name", lambda x, *_: x.name) self.set_field("name", lambda x, *_: x.name)
self.field( self.field(
@ -25,6 +26,9 @@ class GroupQuery(DbModelQueryABC):
) )
self.set_field("roles", self._get_roles) self.set_field("roles", self._get_roles)
self.set_history_reference_dao(shortUrlDao, "groupid")
self.set_history_reference_dao(groupRoleAssignmentDao, "groupid")
@staticmethod @staticmethod
async def _get_urls(group: Group, *_): async def _get_urls(group: Group, *_):
return await shortUrlDao.find_by({ShortUrl.group_id: group.id}) return await shortUrlDao.find_by({ShortUrl.group_id: group.id})

View File

@ -0,0 +1,14 @@
from api_graphql.abc.db_history_model_query_abc import DbHistoryModelQueryABC
class ShortUrlQuery(DbHistoryModelQueryABC):
def __init__(self):
DbHistoryModelQueryABC.__init__(self, "ShortUrl")
self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url)
self.set_field("description", lambda x, *_: x.description)
self.set_field("group", lambda x, *_: x.group)
self.set_field("domain", lambda x, *_: x.domain)
self.set_field("visits", lambda x, *_: x.visit_count)
self.set_field("loadingScreen", lambda x, *_: x.loading_screen)

View File

@ -1,9 +1,10 @@
from api_graphql.abc.db_model_query_abc import DbModelQueryABC from api_graphql.abc.db_model_query_abc import DbModelQueryABC
from data.schemas.public.short_url_dao import shortUrlDao
class ShortUrlQuery(DbModelQueryABC): class ShortUrlQuery(DbModelQueryABC):
def __init__(self): def __init__(self):
DbModelQueryABC.__init__(self, "ShortUrl") DbModelQueryABC.__init__(self, "ShortUrl", shortUrlDao, with_history=True)
self.set_field("shortUrl", lambda x, *_: x.short_url) self.set_field("shortUrl", lambda x, *_: x.short_url)
self.set_field("targetUrl", lambda x, *_: x.target_url) self.set_field("targetUrl", lambda x, *_: x.target_url)

View File

@ -49,6 +49,12 @@ class Subscription(SubscriptionABC):
.with_public(True) .with_public(True)
) )
self.subscribe(
SubscriptionFieldBuilder("userLogout")
.with_resolver(lambda message, *_: message.message)
.with_public(True)
)
self.subscribe( self.subscribe(
SubscriptionFieldBuilder("domainChange") SubscriptionFieldBuilder("domainChange")
.with_resolver(lambda message, *_: message.message) .with_resolver(lambda message, *_: message.message)

View File

@ -111,7 +111,11 @@ def _find_short_url_by_path(path: str) -> Optional[dict]:
if "errors" in data: if "errors" in data:
logger.warning(f"Failed to find short url by path {path} -> {data["errors"]}") logger.warning(f"Failed to find short url by path {path} -> {data["errors"]}")
if "data" not in data or "shortUrls" not in data["data"] or "nodes" not in data["data"]["shortUrls"]: if (
"data" not in data
or "shortUrls" not in data["data"]
or "nodes" not in data["data"]["shortUrls"]
):
return None return None
data = data["data"]["shortUrls"]["nodes"] data = data["data"]["shortUrls"]["nodes"]

View File

@ -1,11 +1,16 @@
<main *ngIf="isLoggedIn && !hideUI; else home" [class]="theme"> <main [class]="theme">
<div
class="warning bg3 flex justify-center p-1.5"
*ngIf="showTechnicalDemoBanner">
{{ 'technical_demo_banner' | translate }}
</div>
<app-header></app-header> <app-header></app-header>
<div class="app"> <div class="app">
<aside *ngIf="showSidebar"> <aside *ngIf="showSidebar">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
</aside> </aside>
<section class="component"> <section class="component" *ngIf="loadedGuiSettings">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</section> </section>
</div> </div>
@ -30,7 +35,3 @@
</p-confirmDialog> </p-confirmDialog>
</main> </main>
<app-spinner></app-spinner> <app-spinner></app-spinner>
<ng-template #home>
<router-outlet></router-outlet>
</ng-template>

View File

@ -2,8 +2,8 @@ import { Component, OnDestroy } from '@angular/core';
import { SidebarService } from 'src/app/service/sidebar.service'; import { SidebarService } from 'src/app/service/sidebar.service';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { AuthService } from 'src/app/service/auth.service';
import { GuiService } from 'src/app/service/gui.service'; import { GuiService } from 'src/app/service/gui.service';
import { FeatureFlagService } from 'src/app/service/feature-flag.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -11,36 +11,35 @@ import { GuiService } from 'src/app/service/gui.service';
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent implements OnDestroy { export class AppComponent implements OnDestroy {
theme = 'open-redirect';
showSidebar = false; showSidebar = false;
hideUI = false; theme = 'lan-maestro';
isLoggedIn = false; showTechnicalDemoBanner = false;
loadedGuiSettings = false;
unsubscribe$ = new Subject<void>(); unsubscribe$ = new Subject<void>();
constructor( constructor(
private sidebar: SidebarService, private sidebar: SidebarService,
private auth: AuthService, private gui: GuiService,
private gui: GuiService private features: FeatureFlagService
) { ) {
this.auth.loadUser(); this.features.get('TechnicalDemoBanner').then(showTechnicalDemoBanner => {
this.showTechnicalDemoBanner = showTechnicalDemoBanner;
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(user => {
this.isLoggedIn = user !== null && user !== undefined;
}); });
this.sidebar.visible$ this.sidebar.visible$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe(visible => { .subscribe(visible => {
this.showSidebar = visible; this.showSidebar = visible;
}); });
this.gui.hideGui$.pipe(takeUntil(this.unsubscribe$)).subscribe(hide => {
this.hideUI = hide;
});
this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => { this.gui.theme$.pipe(takeUntil(this.unsubscribe$)).subscribe(theme => {
this.theme = theme; this.theme = theme;
}); });
this.gui.loadedGuiSettings$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(loaded => {
this.loadedGuiSettings = loaded;
});
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -1,4 +1,11 @@
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core'; import {
APP_INITIALIZER,
ApplicationRef,
DoBootstrap,
ErrorHandler,
Injector,
NgModule,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@ -23,6 +30,8 @@ import { SidebarComponent } from './components/sidebar/sidebar.component';
import { ErrorHandlingService } from 'src/app/service/error-handling.service'; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component'; import { ServerUnavailableComponent } from 'src/app/components/error/server-unavailable/server-unavailable.component';
import { SpinnerService } from 'src/app/service/spinner.service';
import { AuthService } from 'src/app/service/auth.service';
if (environment.production) { if (environment.production) {
Logger.enableProductionMode(); Logger.enableProductionMode();
@ -95,6 +104,20 @@ export function appInitializerFactory(
useClass: ErrorHandlingService, useClass: ErrorHandlingService,
}, },
], ],
bootstrap: [AppComponent],
}) })
export class AppModule {} export class AppModule implements DoBootstrap {
constructor(private injector: Injector) {}
async ngDoBootstrap(appRef: ApplicationRef) {
const spinner = this.injector.get(SpinnerService);
spinner.show();
const auth = this.injector.get(AuthService);
const user = await auth.loadUser();
if (!user) {
await auth.login();
}
appRef.bootstrap(AppComponent);
}
}

View File

@ -1,8 +1,9 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ErrorComponentBase } from 'src/app/core/base/error-component-base';
@Component({ @Component({
selector: 'app-not-found', selector: 'app-not-found',
templateUrl: './not-found.component.html', templateUrl: './not-found.component.html',
styleUrls: ['./not-found.component.scss'], styleUrls: ['./not-found.component.scss'],
}) })
export class NotFoundComponent {} export class NotFoundComponent extends ErrorComponentBase {}

View File

@ -1,13 +1,16 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ErrorComponentBase } from 'src/app/core/base/error-component-base';
@Component({ @Component({
selector: 'app-server-unavailable', selector: 'app-server-unavailable',
templateUrl: './server-unavailable.component.html', templateUrl: './server-unavailable.component.html',
styleUrls: ['./server-unavailable.component.scss'], styleUrls: ['./server-unavailable.component.scss'],
}) })
export class ServerUnavailableComponent { export class ServerUnavailableComponent extends ErrorComponentBase {
constructor(private router: Router) {} constructor(private router: Router) {
super();
}
async retryConnection() { async retryConnection() {
await this.router.navigate(['/']); await this.router.navigate(['/']);

View File

@ -1,7 +1,14 @@
<footer> <footer class="flex justify-between pl-1 pr-1">
<div class="hidden md:block">
<span>web: {{ webVersion }}</span>
<span class="divider"> | </span>
<span>api: {{ apiVersion }}</span>
</div>
<div>
<a [href]="termsUrl">{{ 'footer.terms' | translate }}</a> <a [href]="termsUrl">{{ 'footer.terms' | translate }}</a>
<span class="divider"> | </span> <span class="divider"> | </span>
<a [href]="privacyUrl">{{ 'footer.privacy' | translate }}</a> <a [href]="privacyUrl">{{ 'footer.privacy' | translate }}</a>
<span class="divider"> | </span> <span class="divider"> | </span>
<a [href]="imprintUrl">{{ 'footer.imprint' | translate }}</a> <a [href]="imprintUrl">{{ 'footer.imprint' | translate }}</a>
</div>
</footer> </footer>

View File

@ -1,16 +0,0 @@
@import "../../../styles/constants.scss";
footer {
width: 100%;
min-height: 25px;
padding: 0 5px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
a {
text-decoration: none;
}
}

View File

@ -1,23 +1,37 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FooterComponent } from "src/app/components/footer/footer.component"; import { FooterComponent } from 'src/app/components/footer/footer.component';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { BrowserModule } from "@angular/platform-browser"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { AuthService } from 'src/app/service/auth.service';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
describe("FooterComponent", () => { describe('FooterComponent', () => {
let component: FooterComponent; let component: FooterComponent;
let fixture: ComponentFixture<FooterComponent>; let fixture: ComponentFixture<FooterComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [FooterComponent], declarations: [FooterComponent],
imports: [ imports: [SharedModule, TranslateModule.forRoot()],
BrowserModule, providers: [
BrowserAnimationsModule, AuthService,
SharedModule, KeycloakService,
TranslateModule.forRoot(), ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
], ],
}).compileComponents(); }).compileComponents();
}); });
@ -28,7 +42,7 @@ describe("FooterComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,5 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ConfigService } from 'src/app/service/config.service'; import { ConfigService } from 'src/app/service/config.service';
import { VersionService } from 'src/app/service/version.service';
import { ToastService } from 'src/app/service/toast.service';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
@ -7,7 +9,21 @@ import { ConfigService } from 'src/app/service/config.service';
styleUrls: ['./footer.component.scss'], styleUrls: ['./footer.component.scss'],
}) })
export class FooterComponent { export class FooterComponent {
constructor(private config: ConfigService) {} webVersion = '0.0.0';
apiVersion = '0.0.0';
constructor(
private toast: ToastService,
private config: ConfigService,
private version: VersionService
) {
this.version.getApiVersion().subscribe(version => {
this.apiVersion = version;
});
this.version.getWebVersion().subscribe(version => {
this.webVersion = version.version;
});
}
get termsUrl(): string { get termsUrl(): string {
return this.config.settings.termsUrl; return this.config.settings.termsUrl;

View File

@ -2,22 +2,25 @@
<div class="header"> <div class="header">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<p-button <p-button
*ngIf="user" type="button"
icon="pi pi-bars" icon="pi pi-bars"
class="btn icon-btn p-button-text" class="btn icon-btn p-button-text"
(onClick)="toggleSidebar()" (onClick)="toggleSidebar()"></p-button>
></p-button>
</div> </div>
<div class="logo"> <div class="logo">
<!-- <img src="/assets/images/logo.svg" alt="logo"/>--> <!-- <img src="/assets/images/logo.svg" alt="logo"/>-->
</div> </div>
<div class="app-name"> <div class="app-name">
<h1>Open-redirect</h1> <h1>LAN-Maestro</h1>
</div> </div>
</div> </div>
<div class="flex items-center justify-center w-1/3" *ngIf="menu.length > 0">
<app-menu-bar class="w-full" [elements]="menu"></app-menu-bar>
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center" *ngIf="themeList.length > 0">
<p-button <p-button
type="button" type="button"
icon="pi pi-palette" icon="pi pi-palette"
@ -29,7 +32,7 @@
[model]="themeList" [model]="themeList"
class="lang-menu"></p-menu> class="lang-menu"></p-menu>
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center" *ngIf="langList.length > 0">
<p-button <p-button
type="button" type="button"
icon="pi pi-globe" icon="pi pi-globe"

View File

@ -1,16 +1,16 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderComponent } from "src/app/components/header/header.component"; import { HeaderComponent } from 'src/app/components/header/header.component';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { ConfirmationService, MessageService } from "primeng/api"; import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from '@angular/router';
import { of } from "rxjs"; import { of } from 'rxjs';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { ErrorHandlingService } from "src/app/service/error-handling.service"; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
describe("HeaderComponent", () => { describe('HeaderComponent', () => {
let component: HeaderComponent; let component: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
@ -41,7 +41,7 @@ describe("HeaderComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -48,12 +48,12 @@ export class HeaderComponent implements OnInit, OnDestroy {
}); });
this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => { this.auth.user$.pipe(takeUntil(this.unsubscribe$)).subscribe(async user => {
this.user = user;
await this.initMenuLists(); await this.initMenuLists();
if (user) {
await this.loadTheme(); await this.loadTheme();
await this.loadLang(); await this.loadLang();
}
this.user = user;
this.guiService.loadedGuiSettings$.next(true);
}); });
this.themeList = this.config.settings.themes.map(theme => { this.themeList = this.config.settings.themes.map(theme => {
@ -87,27 +87,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
} }
async initMenuList() { async initMenuList() {
this.menu = [ 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() { async initLangMenuList() {

View File

@ -0,0 +1 @@
<p>home works!</p>

View File

@ -1,15 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HomeComponent } from './home.component'; import { HomeComponent } from './home.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
describe('HomeComponent', () => { describe('HomeComponent', () => {
let component: HomeComponent; let component: HomeComponent;
@ -18,21 +9,6 @@ describe('HomeComponent', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [HomeComponent], declarations: [HomeComponent],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(HomeComponent); fixture = TestBed.createComponent(HomeComponent);

View File

@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { KeycloakService } from 'keycloak-angular'; import { SpinnerService } from 'src/app/service/spinner.service';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@ -7,9 +7,7 @@ import { KeycloakService } from 'keycloak-angular';
styleUrl: './home.component.scss', styleUrl: './home.component.scss',
}) })
export class HomeComponent { export class HomeComponent {
constructor(private keycloak: KeycloakService) { constructor(private spinner: SpinnerService) {
if (!this.keycloak.isLoggedIn()) { this.spinner.hide();
this.keycloak.login().then(() => {});
}
} }
} }

View File

@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SidebarComponent } from "./sidebar.component"; import { SidebarComponent } from './sidebar.component';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { ErrorHandlingService } from "src/app/service/error-handling.service"; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from "primeng/api"; import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from '@angular/router';
import { of } from "rxjs"; import { of } from 'rxjs';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
describe("SidebarComponent", () => { describe('SidebarComponent', () => {
let component: SidebarComponent; let component: SidebarComponent;
let fixture: ComponentFixture<SidebarComponent>; let fixture: ComponentFixture<SidebarComponent>;
@ -40,7 +40,7 @@ describe("SidebarComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,13 +1,13 @@
import { Component, OnDestroy } from "@angular/core"; import { Component, OnDestroy } from '@angular/core';
import { MenuElement } from "src/app/model/view/menu-element"; import { MenuElement } from 'src/app/model/view/menu-element';
import { Subject } from "rxjs"; import { Subject } from 'rxjs';
import { SidebarService } from "src/app/service/sidebar.service"; import { SidebarService } from 'src/app/service/sidebar.service';
import { takeUntil } from "rxjs/operators"; import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: "app-sidebar", selector: 'app-sidebar',
templateUrl: "./sidebar.component.html", templateUrl: './sidebar.component.html',
styleUrl: "./sidebar.component.scss", styleUrl: './sidebar.component.scss',
}) })
export class SidebarComponent implements OnDestroy { export class SidebarComponent implements OnDestroy {
elements: MenuElement[] = []; elements: MenuElement[] = [];
@ -17,7 +17,7 @@ export class SidebarComponent implements OnDestroy {
constructor(private sidebar: SidebarService) { constructor(private sidebar: SidebarService) {
this.sidebar.elements$ this.sidebar.elements$
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe((elements) => { .subscribe(elements => {
this.elements = elements; this.elements = elements;
}); });
} }

View File

@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SpinnerComponent } from "src/app/components/spinner/spinner.component"; import { SpinnerComponent } from 'src/app/components/spinner/spinner.component';
describe("SpinnerComponent", () => { describe('SpinnerComponent', () => {
let component: SpinnerComponent; let component: SpinnerComponent;
let fixture: ComponentFixture<SpinnerComponent>; let fixture: ComponentFixture<SpinnerComponent>;
@ -18,7 +18,7 @@ describe("SpinnerComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,16 +1,16 @@
import { Component } from "@angular/core"; import { Component } from '@angular/core';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
@Component({ @Component({
selector: "app-spinner", selector: 'app-spinner',
templateUrl: "./spinner.component.html", templateUrl: './spinner.component.html',
styleUrls: ["./spinner.component.scss"], styleUrls: ['./spinner.component.scss'],
}) })
export class SpinnerComponent { export class SpinnerComponent {
showSpinnerState = false; showSpinnerState = false;
constructor(public spinnerService: SpinnerService) { constructor(public spinnerService: SpinnerService) {
this.spinnerService.showSpinnerState$.subscribe((value) => { this.spinnerService.showSpinnerState$.subscribe(value => {
this.showSpinnerState = value; this.showSpinnerState = value;
}); });
} }

View File

@ -0,0 +1,9 @@
import { inject } from '@angular/core';
import { SpinnerService } from 'src/app/service/spinner.service';
export class ErrorComponentBase {
constructor() {
const spinner = inject(SpinnerService);
spinner.hide();
}
}

View File

@ -26,8 +26,8 @@ export abstract class FormPageBase<
protected filterService = inject(FilterService); protected filterService = inject(FilterService);
protected dataService = inject(PageDataService) as S; protected dataService = inject(PageDataService) as S;
protected constructor() { protected constructor(idKey: string = 'id') {
const id = this.route.snapshot.params['id']; const id = this.route.snapshot.params[idKey];
this.validateRoute(id); this.validateRoute(id);
this.buildForm(); this.buildForm();

View File

@ -115,7 +115,6 @@ export abstract class PageBase<
.onChange() .onChange()
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
.subscribe(() => { .subscribe(() => {
logger.debug('Reload data');
this.load(true); this.load(true);
}); });
} }

View File

@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { MutationResult } from 'apollo-angular';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { QueryResult } from 'src/app/model/entities/query-result';
import { DbModel } from 'src/app/model/entities/db-model';
@Injectable({
providedIn: 'root',
})
export abstract class PageWithHistoryDataService<T> {
abstract load(
filter?: Filter[],
sort?: Sort[],
skip?: number,
take?: number
): Observable<QueryResult<T>>;
abstract loadHistory(id: number, options?: object): Observable<DbModel[]>;
abstract onChange(): Observable<void>;
}
export interface Create<T, C> {
create(object: C): Observable<T | undefined> | Observable<MutationResult>;
}
export interface Update<T, U> {
update(object: U): Observable<T | undefined> | Observable<MutationResult>;
}
export interface Delete<T> {
delete(
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}
export interface Restore<T> {
restore(
object: T
): Observable<T | undefined | boolean> | Observable<MutationResult>;
}
export interface LoadHistory<T> {
loadHistory(
id: number
): Observable<T | undefined | boolean> | Observable<DbModel[] | undefined>;
}

View File

@ -14,6 +14,7 @@ export const ID_COLUMN = {
translationKey: 'common.id', translationKey: 'common.id',
type: 'number', type: 'number',
filterable: true, filterable: true,
sortable: true,
value: (row: { id?: number }) => row.id, value: (row: { id?: number }) => row.id,
class: 'max-w-24', class: 'max-w-24',
}; };
@ -23,6 +24,7 @@ export const NAME_COLUMN = {
translationKey: 'common.name', translationKey: 'common.name',
type: 'text', type: 'text',
filterable: true, filterable: true,
sortable: true,
value: (row: { name?: string }) => row.name, value: (row: { name?: string }) => row.name,
}; };
@ -31,6 +33,7 @@ export const DESCRIPTION_COLUMN = {
translationKey: 'common.description', translationKey: 'common.description',
type: 'text', type: 'text',
filterable: true, filterable: true,
sortable: true,
value: (row: { description?: string }) => row.description, value: (row: { description?: string }) => row.description,
}; };
@ -38,35 +41,49 @@ export const DELETED_COLUMN = {
name: 'deleted', name: 'deleted',
translationKey: 'common.deleted', translationKey: 'common.deleted',
type: 'bool', type: 'bool',
filterable: true, filterable: false,
sortable: true,
value: (row: DbModel) => row.deleted, value: (row: DbModel) => row.deleted,
visible: false,
}; };
export const EDITOR_COLUMN = { export const EDITOR_COLUMN = {
name: 'editor', name: 'editor',
translationKey: 'common.editor', translationKey: 'common.editor',
type: 'text',
filterable: true,
value: (row: DbModel) => row.editor?.username, value: (row: DbModel) => row.editor?.username,
filterSelector: (mode: string, value: unknown) => {
return { editor: { username: { [mode]: value } } };
},
class: 'max-w-32',
visible: false,
}; };
export const CREATED_UTC_COLUMN = { export const CREATED_UTC_COLUMN = {
name: 'createdUtc', name: 'created',
translationKey: 'common.created', translationKey: 'common.created',
type: 'date', type: 'date',
filterable: true, filterable: true,
value: (row: DbModel) => row.createdUtc, sortable: true,
value: (row: DbModel) => row.created,
class: 'max-w-32', class: 'max-w-32',
visible: false,
}; };
export const UPDATED_UTC_COLUMN = { export const UPDATED_UTC_COLUMN = {
name: 'updatedUtc', name: 'updated',
translationKey: 'common.updated', translationKey: 'common.updated',
type: 'date', type: 'date',
filterable: true, filterable: true,
value: (row: DbModel) => row.updatedUtc, sortable: true,
value: (row: DbModel) => row.updated,
class: 'max-w-32', class: 'max-w-32',
visible: false,
}; };
export const DB_MODEL_COLUMNS = [ export const DB_MODEL_COLUMNS = [
DELETED_COLUMN,
EDITOR_COLUMN, EDITOR_COLUMN,
CREATED_UTC_COLUMN, CREATED_UTC_COLUMN,
UPDATED_UTC_COLUMN, UPDATED_UTC_COLUMN,

View File

@ -1,22 +1,39 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { CanActivate } from "@angular/router"; import { CanActivate, Router } from '@angular/router';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
import { Logger } from 'src/app/service/logger.service';
import { AuthService } from 'src/app/service/auth.service';
const logger = new Logger('AuthGuard');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private keycloak: KeycloakService) {} constructor(
private keycloak: KeycloakService,
private auth: AuthService,
private router: Router
) {}
async canActivate(): Promise<boolean> { async canActivate(): Promise<boolean> {
try {
if (!this.keycloak.isLoggedIn()) {
logger.debug('User not logged in, redirecting to login page');
await this.auth.login();
}
if (this.keycloak.isTokenExpired()) { if (this.keycloak.isTokenExpired()) {
logger.debug('Token expired, updating token');
await this.keycloak.updateToken(); await this.keycloak.updateToken();
} }
} catch (err) {
if (!this.keycloak.isLoggedIn()) { logger.error('Error during authentication', err);
await this.keycloak.login(); await this.router.navigate(['/']);
return false;
} }
logger.debug('Check is user logged in');
return this.keycloak.isLoggedIn(); return this.keycloak.isLoggedIn();
} }
} }

View File

@ -1,24 +1,24 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router } from "@angular/router"; import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Logger } from "src/app/service/logger.service"; import { Logger } from 'src/app/service/logger.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
const log = new Logger("PermissionGuard"); const log = new Logger('PermissionGuard');
@Injectable({ @Injectable({
providedIn: "root", providedIn: 'root',
}) })
export class PermissionGuard { export class PermissionGuard {
constructor( constructor(
private router: Router, private router: Router,
private toast: ToastService, private toast: ToastService,
private auth: AuthService, private auth: AuthService
) {} ) {}
async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> { async canActivate(route: ActivatedRouteSnapshot): Promise<boolean> {
const permissions = route.data["permissions"] as PermissionsEnum[]; const permissions = route.data['permissions'] as PermissionsEnum[];
if (!permissions || permissions.length === 0) { if (!permissions || permissions.length === 0) {
return true; return true;
@ -26,11 +26,11 @@ export class PermissionGuard {
const validate = await this.auth.hasAnyPermissionLazy(permissions); const validate = await this.auth.hasAnyPermissionLazy(permissions);
if (!validate) { if (!validate) {
log.debug("Permission denied", permissions); log.debug('Permission denied', permissions);
this.toast.warn("common.warning", "error.permission_denied"); this.toast.warn('common.warning', 'error.permission_denied');
this.router.navigate(["/"]).then(); this.router.navigate(['/']).then();
} }
log.debug("Permission granted", permissions); log.debug('Permission granted', permissions);
return validate; return validate;
} }
} }

View File

@ -2,27 +2,55 @@ import { HttpInterceptorFn } from '@angular/common/http';
import { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs'; import { from, switchMap } from 'rxjs';
import { ConfigService } from 'src/app/service/config.service';
import { catchError } from 'rxjs/operators';
import { AuthService } from 'src/app/service/auth.service';
export const tokenInterceptor: HttpInterceptorFn = (req, next) => { export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const keycloak = inject(KeycloakService); const config = inject(ConfigService);
if (
!config.settings.api.url ||
!req.url.startsWith(config.settings.api.url)
) {
return next(req);
}
const keycloak = inject(KeycloakService);
if (!keycloak.isLoggedIn()) { if (!keycloak.isLoggedIn()) {
return next(req); return next(req);
} }
if (keycloak.isTokenExpired()) {
keycloak.updateToken().then();
}
return from(keycloak.getToken()).pipe( return from(keycloak.getToken()).pipe(
switchMap(token => { switchMap(token => {
const modifiedReq = token if (!token) {
? req.clone({ return next(req);
}
if (!keycloak.isTokenExpired()) {
return next(
req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`), headers: req.headers.set('Authorization', `Bearer ${token}`),
}) })
: req; );
}
return next(modifiedReq); return from(keycloak.updateToken(30)).pipe(
switchMap(() => {
return keycloak.getToken();
}),
switchMap(newToken => {
return next(
req.clone({
headers: req.headers.set('Authorization', `Bearer ${newToken}`),
})
);
}),
catchError(() => {
const auth = inject(AuthService);
auth.logout().then();
return next(req);
})
);
}) })
); );
}; };

View File

@ -2,6 +2,10 @@ export enum PermissionsEnum {
// Administration // Administration
administrator = 'administrator', administrator = 'administrator',
// Settings
settings = 'settings',
settingsUpdate = 'settings.update',
apiKeys = 'api_keys', apiKeys = 'api_keys',
apiKeysCreate = 'api_keys.create', apiKeysCreate = 'api_keys.create',
apiKeysUpdate = 'api_keys.update', apiKeysUpdate = 'api_keys.update',

View File

@ -1,13 +1,14 @@
import { Role } from "src/app/model/entities/role"; import { Role } from 'src/app/model/entities/role';
import { DbModel } from "src/app/model/entities/db-model"; import { DbModelWithHistory } from 'src/app/model/entities/db-model';
export interface NotExistingUser { export interface NotExistingUser {
keycloakId: string; keycloakId: string;
username: string; username: string;
} }
export interface User extends DbModel { export interface User extends DbModelWithHistory {
id: number; id: number;
keycloakId: string;
username: string; username: string;
email: string; email: string;
roles: Role[]; roles: Role[];

View File

@ -1,7 +1,7 @@
import { DbModel } from 'src/app/model/entities/db-model'; import { DbModelWithHistory } from 'src/app/model/entities/db-model';
import { Permission } from 'src/app/model/entities/role'; import { Permission } from 'src/app/model/entities/role';
export interface ApiKey extends DbModel { export interface ApiKey extends DbModelWithHistory {
identifier?: string; identifier?: string;
key?: string; key?: string;
permissions?: Permission[]; permissions?: Permission[];

View File

@ -1,9 +1,19 @@
import { User } from "src/app/model/auth/user"; import { User } from 'src/app/model/auth/user';
export interface DbModelWithHistory {
id?: number;
editor?: User;
deleted?: boolean;
created?: Date;
updated?: Date;
history?: DbModel[];
}
export interface DbModel { export interface DbModel {
id?: number; id?: number;
editor?: User; editor?: User;
deleted?: boolean; deleted?: boolean;
createdUtc?: Date; created?: Date;
updatedUtc?: Date; updated?: Date;
} }

View File

@ -1,9 +1,11 @@
import { DbModel } from "src/app/model/entities/db-model"; import { DbModel, DbModelWithHistory } from 'src/app/model/entities/db-model';
import { User } from 'src/app/model/auth/user';
export interface Role extends DbModel { export interface Role extends DbModelWithHistory {
name?: string; name?: string;
description?: string; description?: string;
permissions?: Permission[]; permissions?: Permission[];
users?: User[];
} }
export interface RoleCreateInput { export interface RoleCreateInput {

View File

@ -1,5 +1,5 @@
import { gql } from "apollo-angular"; import { gql } from 'apollo-angular';
import { EDITOR_FRAGMENT } from "src/app/model/graphql/editor.query"; import { EDITOR_FRAGMENT } from 'src/app/model/graphql/editor.query';
export const DB_MODEL_FRAGMENT = gql` export const DB_MODEL_FRAGMENT = gql`
fragment DB_MODEL on DbModel { fragment DB_MODEL on DbModel {
@ -10,9 +10,21 @@ export const DB_MODEL_FRAGMENT = gql`
editor { editor {
...EDITOR ...EDITOR
} }
createdUtc created
updatedUtc updated
} }
${EDITOR_FRAGMENT} ${EDITOR_FRAGMENT}
`; `;
export const DB_HISTORY_MODEL_FRAGMENT = gql`
fragment DB_HISTORY_MODEL on DbHistoryModel {
__typename
id
deleted
editor
created
updated
}
`;

View File

@ -1,4 +1,4 @@
import { gql } from "apollo-angular"; import { gql } from 'apollo-angular';
export const EDITOR_FRAGMENT = gql` export const EDITOR_FRAGMENT = gql`
fragment EDITOR on User { fragment EDITOR on User {

View File

@ -2,6 +2,6 @@
export type Sort = { [key: string]: any }; export type Sort = { [key: string]: any };
export enum SortOrder { export enum SortOrder {
ASC = "ASC", ASC = 'ASC',
DESC = "DESC", DESC = 'DESC',
} }

View File

@ -7,4 +7,5 @@ export interface MenuElement {
visible?: boolean; visible?: boolean;
disabled?: boolean; disabled?: boolean;
expanded?: boolean; expanded?: boolean;
isSection?: boolean;
} }

View File

@ -1,3 +1,3 @@
export enum Themes { export enum Themes {
Default = "maxlan-dark-theme", Default = 'maxlan-dark-theme',
} }

View File

@ -6,6 +6,11 @@ import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { SharedModule } from 'src/app/modules/shared/shared.module'; import { SharedModule } from 'src/app/modules/shared/shared.module';
const routes: Routes = [ const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'users',
},
{ {
path: 'users', path: 'users',
title: 'Users | Maxlan', title: 'Users | Maxlan',

View File

@ -12,7 +12,10 @@ import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model'; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from 'apollo-angular'; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from 'src/app/model/entities/query-result'; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query'; import {
DB_HISTORY_MODEL_FRAGMENT,
DB_MODEL_FRAGMENT,
} from 'src/app/model/graphql/db-model.query';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service'; import { SpinnerService } from 'src/app/service/spinner.service';
import { import {
@ -20,10 +23,12 @@ import {
ApiKeyCreateInput, ApiKeyCreateInput,
ApiKeyUpdateInput, ApiKeyUpdateInput,
} from 'src/app/model/entities/api-key'; } from 'src/app/model/entities/api-key';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { DbModel } from 'src/app/model/entities/db-model';
@Injectable() @Injectable()
export class ApiKeysDataService export class ApiKeysDataService
extends PageDataService<ApiKey> extends PageWithHistoryDataService<ApiKey>
implements implements
Create<ApiKey, ApiKeyCreateInput>, Create<ApiKey, ApiKeyCreateInput>,
Update<ApiKey, ApiKeyUpdateInput>, Update<ApiKey, ApiKeyUpdateInput>,
@ -91,8 +96,7 @@ export class ApiKeysDataService
.query<{ apiKeys: QueryResult<ApiKey> }>({ .query<{ apiKeys: QueryResult<ApiKey> }>({
query: gql` query: gql`
query getApiKey($id: Int) { query getApiKey($id: Int) {
apiKeys(filter: { id: { equal: $id } }) { apiKey(filter: { id: { equal: $id } }) {
nodes {
id id
identifier identifier
permissions { permissions {
@ -102,7 +106,6 @@ export class ApiKeysDataService
...DB_MODEL ...DB_MODEL
} }
} }
}
${DB_MODEL_FRAGMENT} ${DB_MODEL_FRAGMENT}
`, `,
@ -119,6 +122,42 @@ export class ApiKeysDataService
.pipe(map(result => result.data.apiKeys.nodes[0])); .pipe(map(result => result.data.apiKeys.nodes[0]));
} }
loadHistory(id: number): Observable<DbModel[]> {
return this.apollo
.query<{ apiKeys: QueryResult<ApiKey> }>({
query: gql`
query getApiKeyHistory($id: Int) {
apiKeys(filter: { id: { equal: $id } }) {
count
totalCount
nodes {
history {
id
identifier
permissions {
name
}
...DB_HISTORY_MODEL
}
}
}
}
${DB_HISTORY_MODEL_FRAGMENT}
`,
variables: {
id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.apiKeys?.nodes?.[0]?.history ?? []));
}
onChange(): Observable<void> { onChange(): Observable<void> {
return this.apollo return this.apollo
.subscribe<{ apiKeyChange: void }>({ .subscribe<{ apiKeyChange: void }>({
@ -208,7 +247,7 @@ export class ApiKeysDataService
return this.apollo return this.apollo
.mutate<{ apiKey: { delete: boolean } }>({ .mutate<{ apiKey: { delete: boolean } }>({
mutation: gql` mutation: gql`
mutation deleteApiKey($id: ID!) { mutation deleteApiKey($id: Int!) {
apiKey { apiKey {
delete(id: $id) delete(id: $id)
} }
@ -231,7 +270,7 @@ export class ApiKeysDataService
return this.apollo return this.apollo
.mutate<{ apiKey: { restore: boolean } }>({ .mutate<{ apiKey: { restore: boolean } }>({
mutation: gql` mutation: gql`
mutation restoreApiKey($id: ID!) { mutation restoreApiKey($id: Int!) {
apiKey { apiKey {
restore(id: $id) restore(id: $id)
} }
@ -255,7 +294,7 @@ export class ApiKeysDataService
.query<{ permissions: QueryResult<Permission> }>({ .query<{ permissions: QueryResult<Permission> }>({
query: gql` query: gql`
query getPermissions { query getPermissions {
permissions { permissions(sort: [{ name: ASC }]) {
nodes { nodes {
id id
name name
@ -279,6 +318,10 @@ export class ApiKeysDataService
provide: PageDataService, provide: PageDataService,
useClass: ApiKeysDataService, useClass: ApiKeysDataService,
}, },
{
provide: PageWithHistoryDataService,
useClass: ApiKeysDataService,
},
ApiKeysDataService, ApiKeysDataService,
]; ];
} }

View File

@ -8,6 +8,7 @@ import { ApiKeyFormPageComponent } from 'src/app/modules/admin/administration/ap
import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page'; import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page';
import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service';
import { ApiKeysColumns } from 'src/app/modules/admin/administration/api-keys/api-keys.columns'; import { ApiKeysColumns } from 'src/app/modules/admin/administration/api-keys/api-keys.columns';
import { HistoryComponent } from 'src/app/modules/admin/administration/api-keys/history/history.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -31,12 +32,20 @@ const routes: Routes = [
permissions: [PermissionsEnum.apiKeysUpdate], permissions: [PermissionsEnum.apiKeysUpdate],
}, },
}, },
{
path: 'history/:historyId',
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeys],
},
},
], ],
}, },
]; ];
@NgModule({ @NgModule({
declarations: [ApiKeysPage, ApiKeyFormPageComponent], declarations: [ApiKeysPage, ApiKeyFormPageComponent, HistoryComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [ApiKeysDataService.provide(), ApiKeysColumns.provide()], providers: [ApiKeysDataService.provide(), ApiKeysColumns.provide()],
}) })

View File

@ -11,6 +11,7 @@
[(skip)]="skip" [(skip)]="skip"
[(take)]="take" [(take)]="take"
(load)="load()" (load)="load()"
[history]="true"
[create]="true" [create]="true"
[update]="true" [update]="true"
(delete)="delete($event)" (delete)="delete($event)"

View File

@ -6,7 +6,7 @@
(onClose)="close()"> (onClose)="close()">
<ng-template formPageHeader let-isUpdate> <ng-template formPageHeader let-isUpdate>
<h2> <h2>
{{ 'common.role' | translate }} {{ 'common.api_key' | translate }}
{{ {{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate | translate
@ -21,22 +21,26 @@
</div> </div>
<div class="form-page-input"> <div class="form-page-input">
<p class="label">{{ 'common.identifier' | translate }}</p> <p class="label">{{ 'common.identifier' | translate }}</p>
<input pInputText class="value" type="text" formControlName="identifier"/> <input
pInputText
class="value"
type="text"
formControlName="identifier" />
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<div *ngFor="let group of Object.keys(permissionGroups)"> <div *ngFor="let group of Object.keys(permissionGroups)">
<div class="flex flex-col gap-2"> <div class="flex flex-col">
<div class="flex justify-between"> <div class="flex justify-between">
<label class="flex-1" for="roles.permission_groups.{{ group }}"> <label class="flex-1" for="apiKeys.permission_groups.{{ group }}">
<h3> <h3>
{{ 'permissions.' + group | translate }} {{ 'permissions.' + group | translate }}
</h3> </h3>
</label> </label>
<form #form="ngForm"> <form #form="ngForm">
<p-inputSwitch <p-inputSwitch
name="roles.permission_groups.{{ group }}" name="apiKeys.permission_groups.{{ group }}"
(onChange)="toggleGroup($event, group)" (onChange)="toggleGroup($event, group)"
[ngModel]="isGroupChecked(group)"></p-inputSwitch> [ngModel]="isGroupChecked(group)"></p-inputSwitch>
</form> </form>
@ -44,20 +48,14 @@
<div <div
*ngFor="let permission of permissionGroups[group]" *ngFor="let permission of permissionGroups[group]"
class="flex flex-col"> class="flex items-center justify-between w-full">
<div class="flex items-center justify-between w-full">
<label class="flex-1" for="{{ permission.name }}"> <label class="flex-1" for="{{ permission.name }}">
{{ 'permissions.' + permission.name | translate }} {{ 'permissions.' + permission.name | translate }}
</label> </label>
<p-inputSwitch class="flex items-center justify-center" [formControlName]="permission.name"> <p-inputSwitch [formControlName]="permission.name">
>
</p-inputSwitch> </p-inputSwitch>
</div> </div>
<div *ngIf="permission.description">
<p class="text-sm text-gray-500">
{{ permission.description }}
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,7 +11,6 @@ import {
ApiKeyUpdateInput, ApiKeyUpdateInput,
} from 'src/app/model/entities/api-key'; } from 'src/app/model/entities/api-key';
import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service';
import { TranslateService } from '@ngx-translate/core';
@Component({ @Component({
selector: 'app-api-key-form-page', selector: 'app-api-key-form-page',
@ -27,10 +26,7 @@ export class ApiKeyFormPageComponent extends FormPageBase<
permissionGroups: { [key: string]: Permission[] } = {}; permissionGroups: { [key: string]: Permission[] } = {};
allPermissions: Permission[] = []; allPermissions: Permission[] = [];
constructor( constructor(private toast: ToastService) {
private toast: ToastService,
private translate: TranslateService
) {
super(); super();
this.initializePermissions().then(() => { this.initializePermissions().then(() => {
if (!this.nodeId) { if (!this.nodeId) {
@ -40,8 +36,10 @@ export class ApiKeyFormPageComponent extends FormPageBase<
return; return;
} }
this.dataService.loadById(this.nodeId).subscribe(apiKey => { this.dataService
this.node = apiKey; .load([{ id: { equal: this.nodeId } }])
.subscribe(apiKey => {
this.node = apiKey.nodes[0];
this.setForm(this.node); this.setForm(this.node);
}); });
}); });
@ -51,12 +49,7 @@ export class ApiKeyFormPageComponent extends FormPageBase<
const permissions = await firstValueFrom( const permissions = await firstValueFrom(
this.dataService.getAllPermissions() this.dataService.getAllPermissions()
); );
this.allPermissions = permissions.map(x => { this.allPermissions = permissions;
const key = `permission_descriptions.${x.name}`;
const description = this.translate.instant(key);
x.description = description === key ? undefined : description;
return x;
});
this.permissionGroups = permissions.reduce( this.permissionGroups = permissions.reduce(
(acc, p) => { (acc, p) => {
const group = p.name.includes('.') ? p.name.split('.')[0] : p.name; const group = p.name.includes('.') ? p.name.split('.')[0] : p.name;

View File

@ -0,0 +1,3 @@
<app-history-sidebar
[loadHistory]="loadHistory"
[columns]="columns"></app-history-sidebar>

View File

@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { PageDataService } from 'src/app/core/base/page.data.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service';
import { PageColumns } from 'src/app/core/base/page.columns';
import { MockPageColumns } from 'src/app/modules/shared/test/page.columns.mock';
describe('HistoryComponent', () => {
let component: HistoryComponent<unknown>;
let fixture: ComponentFixture<HistoryComponent<unknown>>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HistoryComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: { historyId: '3' } },
},
},
{
provide: PageDataService,
useClass: ApiKeysDataService,
},
{
provide: PageColumns,
useClass: MockPageColumns,
},
],
}).compileComponents();
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
//eslint-disable-next-line @typescript-eslint/no-unused-vars
component.loadHistory = (id: number) => of([]);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component } from '@angular/core';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { PageColumns } from 'src/app/core/base/page.columns';
@Component({
selector: 'app-history',
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
})
export class HistoryComponent<T> {
loadHistory = (id: number) => this.data.loadHistory(id);
columns = this._columns.get();
constructor(
public data: PageWithHistoryDataService<T>,
private _columns: PageColumns<T>
) {}
}

View File

@ -5,6 +5,8 @@ import { SpinnerService } from 'src/app/service/spinner.service';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { InputSwitchChangeEvent } from 'primeng/inputswitch'; import { InputSwitchChangeEvent } from 'primeng/inputswitch';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { AuthService } from 'src/app/service/auth.service';
@Component({ @Component({
selector: 'app-feature-flags', selector: 'app-feature-flags',
@ -18,10 +20,18 @@ export class FeatureFlagsPage implements OnInit, OnDestroy {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private data: FeatureFlagsDataService private data: FeatureFlagsDataService,
private auth: AuthService
) {} ) {}
ngOnInit() { async ngOnInit() {
const isAllowed = await this.auth.hasAnyPermissionLazy([
PermissionsEnum.administrator,
]);
if (!isAllowed) {
throw new Error('Unauthorized');
}
this.data this.data
.onUpdate() .onUpdate()
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
@ -53,7 +63,14 @@ export class FeatureFlagsPage implements OnInit, OnDestroy {
}); });
} }
change(flag: FeatureFlag, event: InputSwitchChangeEvent) { async change(flag: FeatureFlag, event: InputSwitchChangeEvent) {
const isAllowed = await this.auth.hasAnyPermissionLazy([
PermissionsEnum.administrator,
]);
if (!isAllowed) {
throw new Error('Unauthorized');
}
flag.value = event.checked; flag.value = event.checked;
this.spinner.show(); this.spinner.show();
this.data this.data

View File

@ -0,0 +1,3 @@
<app-history-sidebar
[loadHistory]="loadHistory"
[columns]="columns"></app-history-sidebar>

View File

@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { PageDataService } from 'src/app/core/base/page.data.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service';
import { PageColumns } from 'src/app/core/base/page.columns';
import { MockPageColumns } from 'src/app/modules/shared/test/page.columns.mock';
describe('HistoryComponent', () => {
let component: HistoryComponent<unknown>;
let fixture: ComponentFixture<HistoryComponent<unknown>>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HistoryComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: { historyId: '3' } },
},
},
{
provide: PageDataService,
useClass: RolesDataService,
},
{
provide: PageColumns,
useClass: MockPageColumns,
},
],
}).compileComponents();
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
//eslint-disable-next-line @typescript-eslint/no-unused-vars
component.loadHistory = (id: number) => of([]);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component } from '@angular/core';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { PageColumns } from 'src/app/core/base/page.columns';
@Component({
selector: 'app-history',
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
})
export class HistoryComponent<T> {
loadHistory = (id: number) => this.data.loadHistory(id);
columns = this._columns.get();
constructor(
public data: PageWithHistoryDataService<T>,
private _columns: PageColumns<T>
) {}
}

View File

@ -12,7 +12,23 @@ import { TableColumn } from 'src/app/modules/shared/components/table/table.model
@Injectable() @Injectable()
export class RolesColumns extends PageColumns<Role> { export class RolesColumns extends PageColumns<Role> {
get(): TableColumn<Role>[] { get(): TableColumn<Role>[] {
return [ID_COLUMN, NAME_COLUMN, DESCRIPTION_COLUMN, ...DB_MODEL_COLUMNS]; return [
ID_COLUMN,
NAME_COLUMN,
DESCRIPTION_COLUMN,
{
name: 'users',
translationKey: 'sidebar.users',
type: 'text',
filterable: false,
sortable: false,
value: (row: Role) =>
(row?.users ?? []).map(user => user.username).join(', '),
width: '300px',
visible: false,
},
...DB_MODEL_COLUMNS,
];
} }
static provide(): Provider[] { static provide(): Provider[] {

View File

@ -17,13 +17,18 @@ import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model'; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from 'apollo-angular'; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from 'src/app/model/entities/query-result'; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query'; import {
DB_HISTORY_MODEL_FRAGMENT,
DB_MODEL_FRAGMENT,
} from 'src/app/model/graphql/db-model.query';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service'; import { SpinnerService } from 'src/app/service/spinner.service';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { DbModel } from 'src/app/model/entities/db-model';
@Injectable() @Injectable()
export class RolesDataService export class RolesDataService
extends PageDataService<Role> extends PageWithHistoryDataService<Role>
implements implements
Create<Role, RoleCreateInput>, Create<Role, RoleCreateInput>,
Update<Role, RoleUpdateInput>, Update<Role, RoleUpdateInput>,
@ -59,6 +64,10 @@ export class RolesDataService
id id
name name
description description
users {
id
username
}
...DB_MODEL ...DB_MODEL
} }
@ -99,6 +108,10 @@ export class RolesDataService
id id
name name
} }
users {
id
username
}
...DB_MODEL ...DB_MODEL
} }
@ -120,6 +133,44 @@ export class RolesDataService
.pipe(map(result => result.data.roles.nodes[0])); .pipe(map(result => result.data.roles.nodes[0]));
} }
loadHistory(id: number): Observable<DbModel[]> {
return this.apollo
.query<{ roles: QueryResult<Role> }>({
query: gql`
query getRoleHistory($id: Int) {
roles(filter: { id: { equal: $id } }) {
count
totalCount
nodes {
history {
id
name
description
permissions {
name
}
...DB_HISTORY_MODEL
}
}
}
}
${DB_HISTORY_MODEL_FRAGMENT}
`,
variables: {
id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.roles?.nodes?.[0]?.history ?? []));
}
onChange(): Observable<void> { onChange(): Observable<void> {
return this.apollo return this.apollo
.subscribe<{ roleChange: void }>({ .subscribe<{ roleChange: void }>({
@ -213,7 +264,7 @@ export class RolesDataService
return this.apollo return this.apollo
.mutate<{ role: { delete: boolean } }>({ .mutate<{ role: { delete: boolean } }>({
mutation: gql` mutation: gql`
mutation deleteRole($id: ID!) { mutation deleteRole($id: Int!) {
role { role {
delete(id: $id) delete(id: $id)
} }
@ -236,7 +287,7 @@ export class RolesDataService
return this.apollo return this.apollo
.mutate<{ role: { restore: boolean } }>({ .mutate<{ role: { restore: boolean } }>({
mutation: gql` mutation: gql`
mutation restoreRole($id: ID!) { mutation restoreRole($id: Int!) {
role { role {
restore(id: $id) restore(id: $id)
} }
@ -260,7 +311,7 @@ export class RolesDataService
.query<{ permissions: QueryResult<Permission> }>({ .query<{ permissions: QueryResult<Permission> }>({
query: gql` query: gql`
query getPermissions { query getPermissions {
permissions { permissions(sort: [{ name: ASC }]) {
nodes { nodes {
id id
name name
@ -284,6 +335,10 @@ export class RolesDataService
provide: PageDataService, provide: PageDataService,
useClass: RolesDataService, useClass: RolesDataService,
}, },
{
provide: PageWithHistoryDataService,
useClass: RolesDataService,
},
RolesDataService, RolesDataService,
]; ];
} }

View File

@ -8,6 +8,7 @@ import { RoleFormPageComponent } from 'src/app/modules/admin/administration/role
import { RolesPage } from 'src/app/modules/admin/administration/roles/roles.page'; import { RolesPage } from 'src/app/modules/admin/administration/roles/roles.page';
import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service'; import { RolesDataService } from 'src/app/modules/admin/administration/roles/roles.data.service';
import { RolesColumns } from 'src/app/modules/admin/administration/roles/roles.columns'; import { RolesColumns } from 'src/app/modules/admin/administration/roles/roles.columns';
import { HistoryComponent } from 'src/app/modules/admin/administration/roles/history/history.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -31,12 +32,20 @@ const routes: Routes = [
permissions: [PermissionsEnum.rolesUpdate], permissions: [PermissionsEnum.rolesUpdate],
}, },
}, },
{
path: 'history/:historyId',
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.roles],
},
},
], ],
}, },
]; ];
@NgModule({ @NgModule({
declarations: [RolesPage, RoleFormPageComponent], declarations: [RolesPage, RoleFormPageComponent, HistoryComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [RolesDataService.provide(), RolesColumns.provide()], providers: [RolesDataService.provide(), RolesColumns.provide()],
}) })

View File

@ -11,6 +11,7 @@
[(skip)]="skip" [(skip)]="skip"
[(take)]="take" [(take)]="take"
(load)="load()" (load)="load()"
[history]="true"
[create]="true" [create]="true"
[update]="true" [update]="true"
(delete)="delete($event)" (delete)="delete($event)"

View File

@ -5,6 +5,8 @@ import { SpinnerService } from 'src/app/service/spinner.service';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { AuthService } from 'src/app/service/auth.service';
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
@ -24,10 +26,18 @@ export class SettingsPage implements OnInit, OnDestroy {
constructor( constructor(
private spinner: SpinnerService, private spinner: SpinnerService,
private data: SettingsDataService, private data: SettingsDataService,
private fb: FormBuilder private fb: FormBuilder,
private auth: AuthService
) {} ) {}
ngOnInit() { async ngOnInit() {
const isAllowed = await this.auth.hasAnyPermissionLazy([
PermissionsEnum.settings,
]);
if (!isAllowed) {
throw new Error('Unauthorized');
}
this.data this.data
.onUpdate() .onUpdate()
.pipe(takeUntil(this.unsubscribe$)) .pipe(takeUntil(this.unsubscribe$))
@ -66,7 +76,14 @@ export class SettingsPage implements OnInit, OnDestroy {
}); });
} }
save() { async save() {
const isAllowed = await this.auth.hasAnyPermissionLazy([
PermissionsEnum.settingsUpdate,
]);
if (!isAllowed) {
throw new Error('Unauthorized');
}
if (this.settingsForm.pristine) { if (this.settingsForm.pristine) {
return; return;
} }

View File

@ -15,7 +15,7 @@
</ng-template> </ng-template>
<ng-template formPageContent> <ng-template formPageContent>
<ng-container *ngIf="notExistingUsers; else showId"></ng-container> <ng-container *ngIf="!this.nodeId; else showId">
<div class="form-page-input"> <div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p> <p class="label">{{ 'common.id' | translate }}</p>
<p-dropdown <p-dropdown
@ -24,9 +24,12 @@
optionValue="keycloakId" optionValue="keycloakId"
formControlName="keycloakId"></p-dropdown> formControlName="keycloakId"></p-dropdown>
</div> </div>
</ng-container>
<ng-template #showId> <ng-template #showId>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p> <p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id" /> <input pInputText class="value" type="number" formControlName="id" />
</div>
</ng-template> </ng-template>
<div class="form-page-input"> <div class="form-page-input">

View File

@ -33,7 +33,7 @@ export class UserFormPageComponent extends FormPageBase<
if (!this.nodeId) { if (!this.nodeId) {
this.dataService.getNotExistingUsersFromKeycloak().subscribe(users => { this.dataService.getNotExistingUsersFromKeycloak().subscribe(users => {
this.notExistingUsers = users; this.notExistingUsers = users ?? [];
this.node = this.new(); this.node = this.new();
this.setForm(this.node); this.setForm(this.node);
}); });

View File

@ -0,0 +1,3 @@
<app-history-sidebar
[loadHistory]="loadHistory"
[columns]="columns"></app-history-sidebar>

View File

@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistoryComponent } from './history.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { PageDataService } from 'src/app/core/base/page.data.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MockPageColumns } from 'src/app/modules/shared/test/page.columns.mock';
import { PageColumns } from 'src/app/core/base/page.columns';
import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service';
describe('HistoryComponent', () => {
let component: HistoryComponent<unknown>;
let fixture: ComponentFixture<HistoryComponent<unknown>>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HistoryComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: { historyId: '3' } },
},
},
{
provide: PageDataService,
useClass: UsersDataService,
},
{
provide: PageColumns,
useClass: MockPageColumns,
},
],
}).compileComponents();
fixture = TestBed.createComponent(HistoryComponent);
component = fixture.componentInstance;
//eslint-disable-next-line @typescript-eslint/no-unused-vars
component.loadHistory = (id: number) => of([]);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import { Component } from '@angular/core';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
import { PageColumns } from 'src/app/core/base/page.columns';
@Component({
selector: 'app-history',
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
})
export class HistoryComponent<T> {
loadHistory = (id: number) => this.data.loadHistory(id);
columns = this._columns.get();
constructor(
public data: PageWithHistoryDataService<T>,
private _columns: PageColumns<T>
) {}
}

View File

@ -12,6 +12,14 @@ export class UsersColumns extends PageColumns<User> {
get(): TableColumn<User>[] { get(): TableColumn<User>[] {
return [ return [
ID_COLUMN, ID_COLUMN,
{
name: 'keycloakId',
translationKey: 'user.keycloak_id',
type: 'text',
filterable: true,
sortable: true,
value: (row: User) => row.keycloakId,
},
{ {
name: 'username', name: 'username',
translationKey: 'user.username', translationKey: 'user.username',
@ -28,8 +36,10 @@ export class UsersColumns extends PageColumns<User> {
name: 'roles', name: 'roles',
translationKey: 'common.roles', translationKey: 'common.roles',
type: 'text', type: 'text',
filterable: true, filterable: false,
value: (row: User) => row.roles.map(role => role.name).join(', '), sortable: false,
value: (row: User) =>
(row?.roles ?? []).map(role => role.name).join(', '),
width: '300px', width: '300px',
}, },
...DB_MODEL_COLUMNS, ...DB_MODEL_COLUMNS,

View File

@ -11,7 +11,10 @@ import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort } from 'src/app/model/graphql/filter/sort.model'; import { Sort } from 'src/app/model/graphql/filter/sort.model';
import { Apollo, gql } from 'apollo-angular'; import { Apollo, gql } from 'apollo-angular';
import { QueryResult } from 'src/app/model/entities/query-result'; import { QueryResult } from 'src/app/model/entities/query-result';
import { DB_MODEL_FRAGMENT } from 'src/app/model/graphql/db-model.query'; import {
DB_HISTORY_MODEL_FRAGMENT,
DB_MODEL_FRAGMENT,
} from 'src/app/model/graphql/db-model.query';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { SpinnerService } from 'src/app/service/spinner.service'; import { SpinnerService } from 'src/app/service/spinner.service';
import { import {
@ -21,10 +24,12 @@ import {
UserUpdateInput, UserUpdateInput,
} from 'src/app/model/auth/user'; } from 'src/app/model/auth/user';
import { Role } from 'src/app/model/entities/role'; import { Role } from 'src/app/model/entities/role';
import { DbModel } from 'src/app/model/entities/db-model';
import { PageWithHistoryDataService } from 'src/app/core/base/page-with-history.data.service';
@Injectable() @Injectable()
export class UsersDataService export class UsersDataService
extends PageDataService<User> extends PageWithHistoryDataService<User>
implements implements
Create<User, UserCreateInput>, Create<User, UserCreateInput>,
Update<User, UserUpdateInput>, Update<User, UserUpdateInput>,
@ -61,6 +66,7 @@ export class UsersDataService
keycloakId keycloakId
username username
email email
roles { roles {
id id
name name
@ -127,6 +133,44 @@ export class UsersDataService
.pipe(map(result => result.data.users.nodes[0])); .pipe(map(result => result.data.users.nodes[0]));
} }
loadHistory(id: number): Observable<DbModel[]> {
return this.apollo
.query<{ users: QueryResult<User> }>({
query: gql`
query getUserHistory($id: Int) {
users(filter: { id: { equal: $id } }) {
count
totalCount
nodes {
history {
id
keycloakId
username
email
roles {
name
}
...DB_HISTORY_MODEL
}
}
}
}
${DB_HISTORY_MODEL_FRAGMENT}
`,
variables: {
id,
},
})
.pipe(
catchError(err => {
this.spinner.hide();
throw err;
})
)
.pipe(map(result => result.data?.users?.nodes?.[0]?.history ?? []));
}
onChange(): Observable<void> { onChange(): Observable<void> {
return this.apollo return this.apollo
.subscribe<{ userChange: void }>({ .subscribe<{ userChange: void }>({
@ -213,7 +257,7 @@ export class UsersDataService
return this.apollo return this.apollo
.mutate<{ user: { delete: boolean } }>({ .mutate<{ user: { delete: boolean } }>({
mutation: gql` mutation: gql`
mutation deleteUser($id: ID!) { mutation deleteUser($id: Int!) {
user { user {
delete(id: $id) delete(id: $id)
} }
@ -236,7 +280,7 @@ export class UsersDataService
return this.apollo return this.apollo
.mutate<{ user: { restore: boolean } }>({ .mutate<{ user: { restore: boolean } }>({
mutation: gql` mutation: gql`
mutation restoreUser($id: ID!) { mutation restoreUser($id: Int!) {
user { user {
restore(id: $id) restore(id: $id)
} }
@ -280,16 +324,14 @@ export class UsersDataService
getNotExistingUsersFromKeycloak(): Observable<NotExistingUser[]> { getNotExistingUsersFromKeycloak(): Observable<NotExistingUser[]> {
return this.apollo return this.apollo
.query<{ notExistingUsersFromKeycloak: QueryResult<NotExistingUser> }>({ .query<{ notExistingUsersFromKeycloak: NotExistingUser[] }>({
query: gql` query: gql`
query getNotExistingUsers { query getNotExistingUsers {
notExistingUsersFromKeycloak { notExistingUsersFromKeycloak {
nodes {
keycloakId keycloakId
username username
} }
} }
}
`, `,
}) })
.pipe( .pipe(
@ -298,7 +340,7 @@ export class UsersDataService
throw err; throw err;
}) })
) )
.pipe(map(result => result.data.notExistingUsersFromKeycloak.nodes)); .pipe(map(result => result.data.notExistingUsersFromKeycloak));
} }
static provide(): Provider[] { static provide(): Provider[] {
@ -307,6 +349,10 @@ export class UsersDataService
provide: PageDataService, provide: PageDataService,
useClass: UsersDataService, useClass: UsersDataService,
}, },
{
provide: PageWithHistoryDataService,
useClass: UsersDataService,
},
UsersDataService, UsersDataService,
]; ];
} }

View File

@ -8,6 +8,7 @@ import { UserFormPageComponent } from 'src/app/modules/admin/administration/user
import { UsersPage } from 'src/app/modules/admin/administration/users/users.page'; import { UsersPage } from 'src/app/modules/admin/administration/users/users.page';
import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service'; import { UsersDataService } from 'src/app/modules/admin/administration/users/users.data.service';
import { UsersColumns } from 'src/app/modules/admin/administration/users/users.columns'; import { UsersColumns } from 'src/app/modules/admin/administration/users/users.columns';
import { HistoryComponent } from 'src/app/modules/admin/administration/users/history/history.component';
const routes: Routes = [ const routes: Routes = [
{ {
@ -31,12 +32,20 @@ const routes: Routes = [
permissions: [PermissionsEnum.usersUpdate], permissions: [PermissionsEnum.usersUpdate],
}, },
}, },
{
path: 'history/:historyId',
component: HistoryComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.users],
},
},
], ],
}, },
]; ];
@NgModule({ @NgModule({
declarations: [UsersPage, UserFormPageComponent], declarations: [UsersPage, UserFormPageComponent, HistoryComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [UsersDataService.provide(), UsersColumns.provide()], providers: [UsersDataService.provide(), UsersColumns.provide()],
}) })

View File

@ -11,6 +11,7 @@
[(skip)]="skip" [(skip)]="skip"
[(take)]="take" [(take)]="take"
(load)="load()" (load)="load()"
[history]="true"
[create]="true" [create]="true"
[update]="true" [update]="true"
(delete)="delete($event)" (delete)="delete($event)"

View File

@ -0,0 +1,32 @@
<p-sidebar
[(visible)]="showSidebar"
position="right"
[baseZIndex]="10000"
styleClass="w-1/3"
(onHide)="close()">
<ng-template pTemplate="header">
<div class="flex justify-between items-center w-full">
<h1 class="text-xl font-bold">
{{ 'history.header' | translate }}
</h1>
</div>
</ng-template>
<div class="h-full">
<div class="flex flex-col gap-1">
<div *ngFor="let entry of history" class="p-4 rounded-lg bg2">
<div
*ngFor="let item of entry | keyvalue"
class="grid grid-cols-6 gap-4 items-center mb-2">
<div class="font-medium col-span-2">
{{ getAttributeTranslationKey(item.key) | translate }}
</div>
<div class="col-span-1 text-center">-></div>
<div class="value col-span-3 overflow-hidden">
{{ item.value }}
</div>
</div>
</div>
</div>
</div>
</p-sidebar>

View File

@ -0,0 +1,54 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HistorySidebarComponent } from 'src/app/modules/shared/components/history/history-sidebar.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('HistorySidebarComponent', () => {
let component: HistorySidebarComponent<unknown>;
let fixture: ComponentFixture<HistorySidebarComponent<unknown>>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [HistorySidebarComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: { historyId: '3' } },
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(HistorySidebarComponent);
component = fixture.componentInstance;
component.columns = [];
//eslint-disable-next-line @typescript-eslint/no-unused-vars
component.loadHistory = (id: number) => of([]);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,138 @@
import { Component, Input, OnInit } from '@angular/core';
import { DbModel } from 'src/app/model/entities/db-model';
import { SpinnerService } from 'src/app/service/spinner.service';
import { User } from 'src/app/model/auth/user';
import { ActivatedRoute, Router } from '@angular/router';
import { TableColumn } from 'src/app/modules/shared/components/table/table.model';
import { Observable } from 'rxjs';
interface History {
id?: number;
editor?: User;
deleted?: boolean;
created?: Date;
updated?: Date;
[x: string | number | symbol]: unknown;
}
@Component({
selector: 'app-history-sidebar',
templateUrl: './history-sidebar.component.html',
styleUrl: './history-sidebar.component.scss',
})
export class HistorySidebarComponent<T> implements OnInit {
@Input({ required: true }) loadHistory!: (
id: number
) => Observable<DbModel[]>;
@Input({ required: true }) columns!: TableColumn<T>[];
historyId!: number;
private translations: { [key: string]: string } = {};
public history: History[] = [];
public showSidebar: boolean = false;
constructor(
private spinner: SpinnerService,
private route: ActivatedRoute,
private router: Router
) {
this.spinner.show();
const id = this.route.snapshot.params['historyId'];
if (!id) {
throw new Error('History ID is required');
}
this.historyId = +id;
}
ngOnInit() {
this.open();
}
open(): void {
this.columns.forEach(column => {
this.translations[column.name] = column.translationKey;
});
this.showSidebar = true;
this.loadHistory(this.historyId).subscribe(data => {
this.history = data ? this.processHistory(data) : [];
this.spinner.hide();
});
let oldHistory: Partial<History> = {};
for (const history of this.history) {
const attributes = Object.keys(history).map(key => {
return key;
});
for (const attribute of attributes) {
if (history[attribute] === oldHistory[attribute]) {
delete oldHistory[attribute];
}
}
oldHistory = history;
}
}
close() {
const backRoute = this.historyId ? '../..' : '..';
this.router.navigate([backRoute], { relativeTo: this.route }).then(() => {
this.spinner.hide();
});
}
private processHistory(data: DbModel[]): History[] {
const history = (data as History[]).map(entry => {
const filteredEntry: History = {};
for (const key in entry) {
if (!key.startsWith('_') && !key.startsWith('__')) {
filteredEntry[key] = this.flattenValue(entry[key]);
}
}
return filteredEntry;
});
const reversedHistory = [...history].reverse();
let cumulativeHistory: Partial<History> = {};
for (const entry of reversedHistory) {
const attributes = Object.keys(entry);
for (const attribute of attributes) {
if (
attribute !== 'editor' &&
entry[attribute] === cumulativeHistory[attribute]
) {
delete entry[attribute];
}
}
cumulativeHistory = { ...cumulativeHistory, ...entry };
}
return reversedHistory.reverse();
}
private flattenValue(value: unknown): string {
if (value === null) {
return '';
}
if (value && typeof value === 'object') {
return (
Array.isArray(value)
? value
: Object.entries(value)
.filter(([key]) => !key.startsWith('_') && !key.startsWith('__'))
.map(([, val]) => val)
)
.map(item => this.flattenValue(item))
.join(', ');
}
return String(value);
}
public getAttributeTranslationKey(key: string): string {
return this.translations[key] || `history.${key}`;
}
}

View File

@ -1,14 +1,38 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MenuBarComponent } from "src/app/modules/shared/components/menu-bar/menu-bar.component"; import { MenuBarComponent } from 'src/app/modules/shared/components/menu-bar/menu-bar.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
describe("MenuBarComponent", () => { describe('MenuBarComponent', () => {
let component: MenuBarComponent; let component: MenuBarComponent;
let fixture: ComponentFixture<MenuBarComponent>; let fixture: ComponentFixture<MenuBarComponent>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [MenuBarComponent], declarations: [MenuBarComponent],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(MenuBarComponent); fixture = TestBed.createComponent(MenuBarComponent);
@ -16,7 +40,7 @@ describe("MenuBarComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SideMenuComponent } from "./side-menu.component"; import { SideMenuComponent } from './side-menu.component';
describe("SideMenuComponent", () => { describe('SideMenuComponent', () => {
let component: SideMenuComponent; let component: SideMenuComponent;
let fixture: ComponentFixture<SideMenuComponent>; let fixture: ComponentFixture<SideMenuComponent>;
@ -16,7 +16,7 @@ describe("SideMenuComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -1,10 +1,10 @@
import { Component, Input } from "@angular/core"; import { Component, Input } from '@angular/core';
import { MenuElement } from "src/app/model/view/menu-element"; import { MenuElement } from 'src/app/model/view/menu-element';
@Component({ @Component({
selector: "app-side-menu", selector: 'app-side-menu',
templateUrl: "./side-menu.component.html", templateUrl: './side-menu.component.html',
styleUrl: "./side-menu.component.scss", styleUrl: './side-menu.component.scss',
}) })
export class SideMenuComponent { export class SideMenuComponent {
@Input() elements: MenuElement[] = []; @Input() elements: MenuElement[] = [];

View File

@ -1,7 +1,7 @@
<p-sidebar <p-sidebar
[visible]="true" [visible]="true"
position="right" position="right"
[style]="{ width: 'min-content', minWidth: '450px' }" [style]="{ width: 'min-content', minWidth: '500px' }"
(onHide)="onClose.emit()" (onHide)="onClose.emit()"
(visibleChange)="!$event ? onClose.emit() : undefined"> (visibleChange)="!$event ? onClose.emit() : undefined">
<ng-template pTemplate="headless"> <ng-template pTemplate="headless">

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormPageComponent } from "src/app/modules/shared/components/slidein/form-page.component"; import { FormPageComponent } from 'src/app/modules/shared/components/slidein/form-page.component';
import { SharedModule } from "src/app/modules/shared/shared.module"; import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from "@ngx-translate/core"; import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from "src/app/service/auth.service"; import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from "keycloak-angular"; import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from "src/app/service/error-handling.service"; import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from "src/app/service/toast.service"; import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from "primeng/api"; import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from '@angular/router';
import { of } from "rxjs"; import { of } from 'rxjs';
import { FormGroup } from "@angular/forms"; import { FormGroup } from '@angular/forms';
import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe("FormpageComponent", () => { describe('FormpageComponent', () => {
let component: FormPageComponent<string>; let component: FormPageComponent<string>;
let fixture: ComponentFixture<FormPageComponent<string>>; let fixture: ComponentFixture<FormPageComponent<string>>;
@ -46,7 +46,7 @@ describe("FormpageComponent", () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it("should create", () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
}); });

View File

@ -5,18 +5,18 @@ import {
Input, Input,
Output, Output,
TemplateRef, TemplateRef,
} from "@angular/core"; } from '@angular/core';
import { FormGroup } from "@angular/forms"; import { FormGroup } from '@angular/forms';
import { SpinnerService } from "src/app/service/spinner.service"; import { SpinnerService } from 'src/app/service/spinner.service';
import { import {
FormPageContentDirective, FormPageContentDirective,
FormPageHeaderDirective, FormPageHeaderDirective,
} from "src/app/modules/shared/form"; } from 'src/app/modules/shared/form';
@Component({ @Component({
selector: "app-form-page", selector: 'app-form-page',
templateUrl: "./form-page.component.html", templateUrl: './form-page.component.html',
styleUrl: "./form-page.component.scss", styleUrl: './form-page.component.scss',
}) })
export class FormPageComponent<T> { export class FormPageComponent<T> {
@Input({ required: true }) formGroup!: FormGroup; @Input({ required: true }) formGroup!: FormGroup;

View File

@ -74,7 +74,7 @@
<tr> <tr>
<th <th
*ngFor="let column of visibleColumns" *ngFor="let column of visibleColumns"
[pSortableColumn]="column.name" [pSortableColumn]="column.sortable != false ? column.name : undefined"
[class]="column.class ?? ''" [class]="column.class ?? ''"
[style.min-width]=" [style.min-width]="
column.minWidth ? column.minWidth + ' !important' : '' column.minWidth ? column.minWidth + ' !important' : ''
@ -85,7 +85,9 @@
"> ">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span>{{ column.translationKey | translate }}</span> <span>{{ column.translationKey | translate }}</span>
<p-sortIcon [field]="column.name"></p-sortIcon> <p-sortIcon
[field]="column.name"
*ngIf="column.sortable != false"></p-sortIcon>
</div> </div>
</th> </th>
<th *ngIf="update || delete.observed || customRowActionsVisible"></th> <th *ngIf="update || delete.observed || customRowActionsVisible"></th>
@ -217,6 +219,13 @@
pTooltip="{{ 'table.restore' | translate }}" pTooltip="{{ 'table.restore' | translate }}"
(click)="restore.emit(row)" (click)="restore.emit(row)"
[disabled]="rowDisabled(row)"></p-button> [disabled]="rowDisabled(row)"></p-button>
<p-button
*ngIf="history"
class="icon-btn btn"
icon="pi pi-history"
tooltipPosition="left"
pTooltip="{{ 'table.history' | translate }}"
routerLink="history/{{ row.id }}"></p-button>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -18,7 +18,7 @@ import { RowsPerPageOption } from 'src/app/service/filter.service';
import { Filter } from 'src/app/model/graphql/filter/filter.model'; import { Filter } from 'src/app/model/graphql/filter/filter.model';
import { Sort, SortOrder } from 'src/app/model/graphql/filter/sort.model'; import { Sort, SortOrder } from 'src/app/model/graphql/filter/sort.model';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { debounceTime, Subject } from 'rxjs'; import { debounceTime, Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { Logger } from 'src/app/service/logger.service'; import { Logger } from 'src/app/service/logger.service';
import { AuthService } from 'src/app/service/auth.service'; import { AuthService } from 'src/app/service/auth.service';
@ -27,6 +27,7 @@ import {
CustomActionsDirective, CustomActionsDirective,
CustomRowActionsDirective, CustomRowActionsDirective,
} from 'src/app/modules/shared/components/table/table'; } from 'src/app/modules/shared/components/table/table';
import { DbModel } from 'src/app/model/entities/db-model';
const logger = new Logger('TableComponent'); const logger = new Logger('TableComponent');
@ -76,12 +77,14 @@ export class TableComponent<T> implements OnInit {
// eslint-disable-next-line @angular-eslint/no-output-native // eslint-disable-next-line @angular-eslint/no-output-native
@Output() load = new EventEmitter<void>(); @Output() load = new EventEmitter<void>();
@Input() loadHistory?: (id: number) => Observable<DbModel[] | undefined>;
@Input() loading = true; @Input() loading = true;
@Input() dataKey = 'id'; @Input() dataKey = 'id';
@Input() responsiveLayout: 'stack' | 'scroll' = 'stack'; @Input() responsiveLayout: 'stack' | 'scroll' = 'stack';
@Input() selectableColumns = true; @Input() selectableColumns = true;
@Input() history = false;
@Input() create = false; @Input() create = false;
@Input() update = false; @Input() update = false;
@Input() createBaseUrl = ''; @Input() createBaseUrl = '';
@ -157,13 +160,6 @@ export class TableComponent<T> implements OnInit {
} }
resolveColumns() { resolveColumns() {
if (!this.rows || this.rows.length == 0) {
this.resolvedColumns = [];
return;
}
const resolvedColumns: ResolvedTableColumn<T>[][] = [];
const columns = this.columns const columns = this.columns
.map(x => { .map(x => {
if (x.visible === undefined || x.visible === null) { if (x.visible === undefined || x.visible === null) {
@ -173,6 +169,12 @@ export class TableComponent<T> implements OnInit {
}) })
.filter(x => !x.hidden && x.visible === true); .filter(x => !x.hidden && x.visible === true);
if (!this.rows || this.rows.length == 0) {
this.resolvedColumns = [];
return;
}
const resolvedColumns: ResolvedTableColumn<T>[][] = [];
this.rows.forEach(row => { this.rows.forEach(row => {
const resolvedRow: ResolvedTableColumn<T>[] = []; const resolvedRow: ResolvedTableColumn<T>[] = [];
columns.forEach(column => { columns.forEach(column => {
@ -297,9 +299,24 @@ export class TableComponent<T> implements OnInit {
}); });
} }
public setFilterForm() { setFilterForm() {
this.filterForm = this.defaultFilterForm; this.filterForm = this.defaultFilterForm;
this.filter.forEach(f => {
Object.keys(f).forEach(key => {
const value = f[key];
if (this.filterForm.contains(key)) {
if (typeof value === 'object' && value !== null) {
Object.keys(value).forEach(subKey => {
this.filterForm.get(key)?.setValue(value[subKey]);
});
} else {
this.filterForm.get(key)?.setValue(value);
}
}
});
});
this.filterForm.valueChanges this.filterForm.valueChanges
.pipe(takeUntil(this.unsubscriber$), debounceTime(200)) .pipe(takeUntil(this.unsubscriber$), debounceTime(200))
.subscribe(changes => { .subscribe(changes => {
@ -345,6 +362,10 @@ export class TableComponent<T> implements OnInit {
const filterMode = column.filterMode || defaultFilterMode; const filterMode = column.filterMode || defaultFilterMode;
if (column.filterSelector) {
return column.filterSelector(filterMode, value);
}
return { [key]: { [filterMode]: value } }; return { [key]: { [filterMode]: value } };
}); });

View File

@ -1,4 +1,5 @@
import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum';
import { Filter } from 'src/app/model/graphql/filter/filter.model';
export type TableColumnValue<T> = T | T[keyof T] | string; export type TableColumnValue<T> = T | T[keyof T] | string;
@ -15,7 +16,7 @@ export interface TableColumn<T> {
filterable?: boolean; filterable?: boolean;
filterMode?: 'contains' | 'startsWith' | 'endsWith' | 'equals'; filterMode?: 'contains' | 'startsWith' | 'endsWith' | 'equals';
sort?: (a: T, b: T) => number; sort?: (a: T, b: T) => number;
filter?: (row: T, filter: string) => boolean; filterSelector?: (mode: string, value: unknown) => Filter;
minWidth?: string; minWidth?: string;
width?: string; width?: string;
maxWidth?: string; maxWidth?: string;

View File

@ -1,17 +1,17 @@
import { addMinutes, format, parseISO } from "date-fns"; import { addMinutes, format, parseISO } from 'date-fns';
export function formatUTCDateToClientTimezone(date: string) { export function formatUTCDateToClientTimezone(date: string) {
const dateTZ = addMinutes( const dateTZ = addMinutes(
parseISO(date), parseISO(date),
new Date().getTimezoneOffset() * -1, new Date().getTimezoneOffset() * -1
); );
return format(dateTZ, "yyyy-MM-dd HH:mm:ss"); return format(dateTZ, 'yyyy-MM-dd HH:mm:ss');
} }
export function formatUTCDateToGermanLocaleAndTimezone(date: string) { export function formatUTCDateToGermanLocaleAndTimezone(date: string) {
const dateTZ = addMinutes( const dateTZ = addMinutes(
parseISO(date), parseISO(date),
new Date().getTimezoneOffset() * -1, new Date().getTimezoneOffset() * -1
); );
return format(dateTZ, "dd.MM.yy HH:mm:ss"); return format(dateTZ, 'dd.MM.yy HH:mm:ss');
} }

View File

@ -1,3 +1,31 @@
export function deepCopy<T>(obj: T): T { export function simpleDeepCopy<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} }
export function deepCopy<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Array) {
const arrCopy = [] as unknown[];
for (let i = 0; i < obj.length; i++) {
arrCopy[i] = deepCopy(obj[i]);
}
return arrCopy as unknown as T;
}
if (obj instanceof Function) {
return obj;
}
const objCopy = {} as { [key: string]: unknown };
for (const key in obj) {
// eslint-disable-next-line no-prototype-builtins
if (obj.hasOwnProperty(key)) {
objCopy[key] = deepCopy((obj as { [key: string]: unknown })[key]);
}
}
return objCopy as T;
}

View File

@ -1,8 +1,8 @@
import { Directive, TemplateRef } from "@angular/core"; import { Directive, TemplateRef } from '@angular/core';
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector // eslint-disable-next-line @angular-eslint/directive-selector
selector: "[formPageHeader]", selector: '[formPageHeader]',
}) })
export class FormPageHeaderDirective { export class FormPageHeaderDirective {
constructor(public templateRef: TemplateRef<unknown>) {} constructor(public templateRef: TemplateRef<unknown>) {}
@ -10,7 +10,7 @@ export class FormPageHeaderDirective {
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector // eslint-disable-next-line @angular-eslint/directive-selector
selector: "[formPageContent]", selector: '[formPageContent]',
}) })
export class FormPageContentDirective { export class FormPageContentDirective {
constructor(public templateRef: TemplateRef<unknown>) {} constructor(public templateRef: TemplateRef<unknown>) {}

Some files were not shown because too many files have changed in this diff Show More