diff --git a/api/src/api_graphql/graphql/group.gql b/api/src/api_graphql/graphql/group.gql index 503b366..82005fe 100644 --- a/api/src/api_graphql/graphql/group.gql +++ b/api/src/api_graphql/graphql/group.gql @@ -7,7 +7,6 @@ type GroupResult { type Group implements DbModel { id: ID name: String - description: String deleted: Boolean editor: User @@ -18,7 +17,6 @@ type Group implements DbModel { input GroupSort { id: SortOrder name: SortOrder - description: SortOrder deleted: SortOrder editorId: SortOrder @@ -29,7 +27,6 @@ input GroupSort { input GroupFilter { id: IntFilter name: StringFilter - description: StringFilter deleted: BooleanFilter editor: IntFilter @@ -37,8 +34,18 @@ input GroupFilter { updatedUtc: DateFilter } -input GroupInput { - id: ID - name: String - description: String +type GroupMutation { + create(input: GroupCreateInput!): Group + update(input: GroupUpdateInput!): Group + delete(id: ID!): Boolean + restore(id: ID!): Boolean } + +input GroupCreateInput { + name: String! +} + +input GroupUpdateInput { + id: ID! + name: String +} \ No newline at end of file diff --git a/api/src/api_graphql/graphql/mutation.gql b/api/src/api_graphql/graphql/mutation.gql index 3886ea6..3b93c89 100644 --- a/api/src/api_graphql/graphql/mutation.gql +++ b/api/src/api_graphql/graphql/mutation.gql @@ -3,4 +3,7 @@ type Mutation { user: UserMutation role: RoleMutation + + group: GroupMutation + shortUrl: ShortUrlMutation } \ No newline at end of file diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index adfdb1b..48a5da3 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -41,8 +41,22 @@ input ShortUrlFilter { updatedUtc: DateFilter } -input ShortUrlInput { - id: ID +type ShortUrlMutation { + create(input: ShortUrlCreateInput!): ShortUrl + update(input: ShortUrlUpdateInput!): ShortUrl + delete(id: ID!): Boolean + restore(id: ID!): Boolean +} + +input ShortUrlCreateInput { + shortUrl: String! + targetUrl: String! + description: String + group: ID +} + +input ShortUrlUpdateInput { + id: ID! shortUrl: String targetUrl: String description: String diff --git a/api/src/api_graphql/input/group_create_input.py b/api/src/api_graphql/input/group_create_input.py new file mode 100644 index 0000000..482bc64 --- /dev/null +++ b/api/src/api_graphql/input/group_create_input.py @@ -0,0 +1,13 @@ +from api_graphql.abc.input_abc import InputABC + + +class GroupCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._name = self.option("name", str, required=True) + + @property + def name(self) -> str: + return self._name diff --git a/api/src/api_graphql/input/group_update_input.py b/api/src/api_graphql/input/group_update_input.py new file mode 100644 index 0000000..a40d665 --- /dev/null +++ b/api/src/api_graphql/input/group_update_input.py @@ -0,0 +1,18 @@ +from api_graphql.abc.input_abc import InputABC + + +class GroupUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._name = self.option("name", str) + + @property + def id(self) -> int: + return self._id + + @property + def name(self) -> str: + return self._name diff --git a/api/src/api_graphql/input/short_url_create_input.py b/api/src/api_graphql/input/short_url_create_input.py new file mode 100644 index 0000000..c60c38c --- /dev/null +++ b/api/src/api_graphql/input/short_url_create_input.py @@ -0,0 +1,30 @@ +from typing import Optional + +from api_graphql.abc.input_abc import InputABC + + +class ShortUrlCreateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._short_url = self.option("shortUrl", str, required=True) + self._target_url = self.option("targetUrl", str, required=True) + self._description = self.option("description", str) + self._group_id = self.option("groupId", int) + + @property + def short_url(self) -> str: + return self._short_url + + @property + def target_url(self) -> str: + return self._target_url + + @property + def description(self) -> Optional[str]: + return self._description + + @property + def group_id(self) -> Optional[int]: + return self._group_id diff --git a/api/src/api_graphql/input/short_url_update_input.py b/api/src/api_graphql/input/short_url_update_input.py new file mode 100644 index 0000000..200cb2d --- /dev/null +++ b/api/src/api_graphql/input/short_url_update_input.py @@ -0,0 +1,35 @@ +from typing import Optional + +from api_graphql.abc.input_abc import InputABC + + +class ShortUrlUpdateInput(InputABC): + + def __init__(self, src: dict): + InputABC.__init__(self, src) + + self._id = self.option("id", int, required=True) + self._short_url = self.option("shortUrl", str) + self._target_url = self.option("targetUrl", str) + self._description = self.option("description", str) + self._group_id = self.option("groupId", int) + + @property + def id(self) -> int: + return self._id + + @property + def short_url(self) -> Optional[str]: + return self._short_url + + @property + def target_url(self) -> Optional[str]: + return self._target_url + + @property + def description(self) -> Optional[str]: + return self._description + + @property + def group_id(self) -> Optional[int]: + return self._group_id diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index e4779cc..77b0f61 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -32,3 +32,13 @@ class Mutation(MutationABC): Permissions.users_delete, ], ) + + self.add_mutation_type( + "group", + "Group", + require_any_permission=[ + Permissions.groups_create, + Permissions.groups_update, + Permissions.groups_delete, + ], + ) diff --git a/api/src/api_graphql/mutations/group_mutation.py b/api/src/api_graphql/mutations/group_mutation.py new file mode 100644 index 0000000..c526366 --- /dev/null +++ b/api/src/api_graphql/mutations/group_mutation.py @@ -0,0 +1,73 @@ +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.group_create_input import GroupCreateInput +from api_graphql.input.group_update_input import GroupUpdateInput +from core.logger import APILogger +from data.schemas.public.group import Group +from data.schemas.public.group_dao import groupDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class GroupMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "Group") + + self.mutation( + "create", + self.resolve_create, + GroupCreateInput, + require_any_permission=[Permissions.groups_create], + ) + self.mutation( + "update", + self.resolve_update, + GroupUpdateInput, + require_any_permission=[Permissions.groups_update], + ) + self.mutation( + "delete", + self.resolve_delete, + require_any_permission=[Permissions.groups_delete], + ) + self.mutation( + "restore", + self.resolve_restore, + require_any_permission=[Permissions.groups_delete], + ) + + @staticmethod + async def resolve_create(obj: GroupCreateInput, *_): + logger.debug(f"create group: {obj.__dict__}") + + group = Group( + 0, + obj.name, + ) + nid = await groupDao.create(group) + return await groupDao.get_by_id(nid) + + @staticmethod + async def resolve_update(obj: GroupUpdateInput, *_): + logger.debug(f"update group: {input}") + + if obj.name is not None: + group = await groupDao.get_by_id(obj.id) + group.name = obj.name + await groupDao.update(group) + + return await groupDao.get_by_id(obj.id) + + @staticmethod + async def resolve_delete(*_, id: str): + logger.debug(f"delete group: {id}") + group = await groupDao.get_by_id(id) + await groupDao.delete(group) + return True + + @staticmethod + async def resolve_restore(*_, id: str): + logger.debug(f"restore group: {id}") + group = await groupDao.get_by_id(id) + await groupDao.restore(group) + return True diff --git a/api/src/api_graphql/mutations/short_url_mutation.py b/api/src/api_graphql/mutations/short_url_mutation.py new file mode 100644 index 0000000..2977683 --- /dev/null +++ b/api/src/api_graphql/mutations/short_url_mutation.py @@ -0,0 +1,92 @@ +from werkzeug.exceptions import NotFound + +from api_graphql.abc.mutation_abc import MutationABC +from api_graphql.input.short_url_create_input import ShortUrlCreateInput +from api_graphql.input.short_url_update_input import ShortUrlUpdateInput +from core.logger import APILogger +from data.schemas.public.group_dao import groupDao +from data.schemas.public.short_url import ShortUrl +from data.schemas.public.short_url_dao import shortUrlDao +from service.permission.permissions_enum import Permissions + +logger = APILogger(__name__) + + +class ShortUrlMutation(MutationABC): + def __init__(self): + MutationABC.__init__(self, "ShortUrl") + + self.mutation( + "create", + self.resolve_create, + ShortUrlCreateInput, + require_any_permission=[Permissions.short_urls_create], + ) + self.mutation( + "update", + self.resolve_update, + ShortUrlUpdateInput, + require_any_permission=[Permissions.short_urls_update], + ) + self.mutation( + "delete", + self.resolve_delete, + require_any_permission=[Permissions.short_urls_delete], + ) + self.mutation( + "restore", + self.resolve_restore, + require_any_permission=[Permissions.short_urls_delete], + ) + + @staticmethod + async def resolve_create(obj: ShortUrlCreateInput, *_): + logger.debug(f"create short_url: {obj.__dict__}") + + short_url = ShortUrl( + 0, + obj.short_url, + obj.target_url, + obj.description, + obj.group_id, + ) + nid = await shortUrlDao.create(short_url) + return await shortUrlDao.get_by_id(nid) + + @staticmethod + async def resolve_update(obj: ShortUrlUpdateInput, *_): + logger.debug(f"update short_url: {input}") + + short_url = await shortUrlDao.get_by_id(obj.id) + + if obj.short_url is not None: + short_url.short_url = obj.short_url + + if obj.target_url is not None: + short_url.target_url = obj.target_url + + if obj.description is not None: + short_url.description = obj.description + + if obj.group_id is not None: + group_by_id = await groupDao.find_by_id(obj.group_id) + if group_by_id is None: + raise NotFound(f"Group with id {obj.group_id} does not exist") + short_url.group_id = obj.group_id + + await shortUrlDao.update(short_url) + return await shortUrlDao.get_by_id(obj.id) + + @staticmethod + async def resolve_delete(*_, id: str): + logger.debug(f"delete short_url: {id}") + short_url = await shortUrlDao.get_by_id(id) + await shortUrlDao.delete(short_url) + return True + + @staticmethod + async def resolve_restore(*_, id: str): + logger.debug(f"restore short_url: {id}") + short_url = await shortUrlDao.get_by_id(id) + await shortUrlDao.restore(short_url) + return True diff --git a/api/src/api_graphql/queries/group_query.py b/api/src/api_graphql/queries/group_query.py index d055c02..ee70246 100644 --- a/api/src/api_graphql/queries/group_query.py +++ b/api/src/api_graphql/queries/group_query.py @@ -6,4 +6,3 @@ class GroupQuery(DbModelQueryABC): DbModelQueryABC.__init__(self, "Group") self.set_field("name", lambda x, *_: x.name) - self.set_field("description", lambda x, *_: x.description) diff --git a/web/src/app/components/header/header.component.html b/web/src/app/components/header/header.component.html index 90a7129..b298046 100644 --- a/web/src/app/components/header/header.component.html +++ b/web/src/app/components/header/header.component.html @@ -10,10 +10,6 @@ -
- -
-
(); + unsubscribe$ = new Subject(); - constructor(private sidebar: SidebarService) { - this.sidebar.elements$ - .pipe(takeUntil(this.unsubscribe$)) - .subscribe(elements => { - this.elements = elements; - }); - } + constructor(private sidebar: SidebarService) { + this.sidebar.elements$ + .pipe(takeUntil(this.unsubscribe$)) + .subscribe(elements => { + this.elements = elements; + }); + } + + ngOnDestroy() { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } } diff --git a/web/src/app/model/auth/permissionsEnum.ts b/web/src/app/model/auth/permissionsEnum.ts index 2958a9f..184b4f2 100644 --- a/web/src/app/model/auth/permissionsEnum.ts +++ b/web/src/app/model/auth/permissionsEnum.ts @@ -1,31 +1,30 @@ export enum PermissionsEnum { // Administration - apiKeys = 'api_keys', - apiKeysCreate = 'api_keys.create', - apiKeysUpdate = 'api_keys.update', - apiKeysDelete = 'api_keys.delete', + apiKeys = "api_keys", + apiKeysCreate = "api_keys.create", + apiKeysUpdate = "api_keys.update", + apiKeysDelete = "api_keys.delete", // Users - users = 'users', - usersCreate = 'users.create', - usersUpdate = 'users.update', - usersDelete = 'users.delete', + users = "users", + usersCreate = "users.create", + usersUpdate = "users.update", + usersDelete = "users.delete", // Permissions - roles = 'roles', - rolesCreate = 'roles.create', - rolesUpdate = 'roles.update', - rolesDelete = 'roles.delete', + roles = "roles", + rolesCreate = "roles.create", + rolesUpdate = "roles.update", + rolesDelete = "roles.delete", // Public - news = 'news', - newsCreate = 'news.create', - newsUpdate = 'news.update', - newsDelete = 'news.delete', + groups = "groups", + groupsCreate = "groups.create", + groupsUpdate = "groups.update", + groupsDelete = "groups.delete", - // Utils - ipList = 'ip_list', - ipListCreate = 'ip_list.create', - ipListUpdate = 'ip_list.update', - ipListDelete = 'ip_list.delete', + shortUrls = "short_urls", + shortUrlsCreate = "short_urls.create", + shortUrlsUpdate = "short_urls.update", + shortUrlsDelete = "short_urls.delete", } diff --git a/web/src/app/model/entities/group.ts b/web/src/app/model/entities/group.ts new file mode 100644 index 0000000..23c945c --- /dev/null +++ b/web/src/app/model/entities/group.ts @@ -0,0 +1,15 @@ +import { DbModel } from "src/app/model/entities/db-model"; +import { Permission } from "src/app/model/entities/role"; + +export interface Group extends DbModel { + name?: string; +} + +export interface GroupCreateInput { + name: string; +} + +export interface GroupUpdateInput { + id: number; + name: string; +} diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index 47350a5..43e0f17 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -1,21 +1,21 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { RouterModule, Routes } from '@angular/router'; -import { SharedModule } from 'src/app/modules/shared/shared.module'; +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { RouterModule, Routes } from "@angular/router"; +import { SharedModule } from "src/app/modules/shared/shared.module"; const routes: Routes = [ { - path: 'public', + path: "groups", loadChildren: () => - import('src/app/modules/admin/public/public.module').then( - m => m.PublicModule + import("src/app/modules/admin/groups/groups.module").then( + (m) => m.GroupsModule, ), }, { - path: 'administration', + path: "administration", loadChildren: () => - import('src/app/modules/admin/administration/administration.module').then( - m => m.AdministrationModule + import("src/app/modules/admin/administration/administration.module").then( + (m) => m.AdministrationModule, ), }, ]; diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.html b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html new file mode 100644 index 0000000..82d8e33 --- /dev/null +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.html @@ -0,0 +1,31 @@ + + +

+ {{ 'common.group' | translate }} + {{ + (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') + | translate + }} +

+
+ + +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.name' | translate }}

+ +
+
+
diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.scss b/web/src/app/modules/admin/groups/form-page/group-form-page.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.spec.ts b/web/src/app/modules/admin/groups/form-page/group-form-page.component.spec.ts new file mode 100644 index 0000000..a4a6c84 --- /dev/null +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.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 { 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 { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +describe('ApiKeyFormpageComponent', () => { + let component: RoleFormPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RoleFormPageComponent], + imports: [ + BrowserAnimationsModule, + SharedModule, + TranslateModule.forRoot(), + ], + providers: [ + AuthService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + ApiKeysDataService, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(RoleFormPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts new file mode 100644 index 0000000..e86ca33 --- /dev/null +++ b/web/src/app/modules/admin/groups/form-page/group-form-page.component.ts @@ -0,0 +1,93 @@ +import { Component } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ToastService } from "src/app/service/toast.service"; +import { FormPageBase } from "src/app/core/base/form-page-base"; +import { + Group, + GroupCreateInput, + GroupUpdateInput, +} from "src/app/model/entities/group"; +import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; + +@Component({ + selector: "app-group-form-page", + templateUrl: "./group-form-page.component.html", + styleUrl: "./group-form-page.component.scss", +}) +export class GroupFormPageComponent extends FormPageBase< + Group, + GroupCreateInput, + GroupUpdateInput, + GroupsDataService +> { + constructor(private toast: ToastService) { + super(); + if (!this.nodeId) { + this.node = this.new(); + this.setForm(this.node); + + return; + } + + this.dataService + .load([{ id: { equal: this.nodeId } }]) + .subscribe((apiKey) => { + this.node = apiKey.nodes[0]; + this.setForm(this.node); + }); + } + + new(): Group { + return {} as Group; + } + + buildForm() { + this.form = new FormGroup({ + id: new FormControl(undefined), + name: new FormControl(undefined, Validators.required), + }); + this.form.controls["id"].disable(); + } + + setForm(node?: Group) { + this.form.controls["id"].setValue(node?.id); + this.form.controls["name"].setValue(node?.name); + } + + getCreateInput(): GroupCreateInput { + return { + name: this.form.controls["name"].pristine + ? undefined + : (this.form.controls["name"].value ?? undefined), + }; + } + + getUpdateInput(): GroupUpdateInput { + if (!this.node?.id) { + throw new Error("Node id is missing"); + } + + return { + id: this.form.controls["id"].value, + name: this.form.controls["name"].pristine + ? undefined + : (this.form.controls["name"].value ?? undefined), + }; + } + + create(apiKey: GroupCreateInput): void { + this.dataService.create(apiKey).subscribe(() => { + this.spinner.hide(); + this.toast.success("action.created"); + this.close(); + }); + } + + update(apiKey: GroupUpdateInput): void { + this.dataService.update(apiKey).subscribe(() => { + this.spinner.hide(); + this.toast.success("action.created"); + this.close(); + }); + } +} diff --git a/web/src/app/modules/admin/groups/groups.columns.ts b/web/src/app/modules/admin/groups/groups.columns.ts new file mode 100644 index 0000000..20cc3c0 --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.columns.ts @@ -0,0 +1,35 @@ +import { Injectable, Provider } from "@angular/core"; +import { + DB_MODEL_COLUMNS, + ID_COLUMN, + PageColumns, +} from "src/app/core/base/page.columns"; +import { TableColumn } from "src/app/modules/shared/components/table/table.model"; +import { Group } from "src/app/model/entities/group"; + +@Injectable() +export class GroupsColumns extends PageColumns { + get(): TableColumn[] { + return [ + ID_COLUMN, + { + name: "name", + label: "common.name", + type: "text", + filterable: true, + value: (row: Group) => row.name, + }, + ...DB_MODEL_COLUMNS, + ]; + } + + static provide(): Provider[] { + return [ + { + provide: PageColumns, + useClass: GroupsColumns, + }, + GroupsColumns, + ]; + } +} diff --git a/web/src/app/modules/admin/groups/groups.data.service.ts b/web/src/app/modules/admin/groups/groups.data.service.ts new file mode 100644 index 0000000..d2620e0 --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.data.service.ts @@ -0,0 +1,232 @@ +import { Injectable, Provider } from "@angular/core"; +import { Observable } from "rxjs"; +import { + Create, + Delete, + PageDataService, + Restore, + Update, +} from "src/app/core/base/page.data.service"; +import { Filter } from "src/app/model/graphql/filter/filter.model"; +import { Sort } from "src/app/model/graphql/filter/sort.model"; +import { Apollo, gql } from "apollo-angular"; +import { QueryResult } from "src/app/model/entities/query-result"; +import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query"; +import { catchError, map } from "rxjs/operators"; +import { SpinnerService } from "src/app/service/spinner.service"; +import { + Group, + GroupCreateInput, + GroupUpdateInput, +} from "src/app/model/entities/group"; + +@Injectable() +export class GroupsDataService + extends PageDataService + implements + Create, + Update, + Delete, + Restore +{ + constructor( + private spinner: SpinnerService, + private apollo: Apollo, + ) { + super(); + } + + load( + filter?: Filter[] | undefined, + sort?: Sort[] | undefined, + skip?: number | undefined, + take?: number | undefined, + ): Observable> { + return this.apollo + .query<{ groups: QueryResult }>({ + query: gql` + query getGroups( + $filter: [GroupFilter] + $sort: [GroupSort] + $skip: Int + $take: Int + ) { + groups(filter: $filter, sort: $sort, skip: $skip, take: $take) { + count + totalCount + nodes { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + filter: filter, + sort: sort, + skip: skip, + take: take, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data.groups)); + } + + loadById(id: number): Observable { + return this.apollo + .query<{ groups: QueryResult }>({ + query: gql` + query getGroup($id: Int) { + group(filter: { id: { equal: $id } }) { + id + name + + ...DB_MODEL + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + id: id, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data.groups.nodes[0])); + } + + create(object: GroupCreateInput): Observable { + return this.apollo + .mutate<{ group: { create: Group } }>({ + mutation: gql` + mutation createGroup($input: GroupCreateInput!) { + group { + create(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + name: object.name, + }, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data?.group.create)); + } + + update(object: GroupUpdateInput): Observable { + return this.apollo + .mutate<{ group: { update: Group } }>({ + mutation: gql` + mutation updateGroup($input: GroupUpdateInput!) { + group { + update(input: $input) { + id + name + + ...DB_MODEL + } + } + } + + ${DB_MODEL_FRAGMENT} + `, + variables: { + input: { + id: object.id, + name: object.name, + }, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data?.group.update)); + } + + delete(object: Group): Observable { + return this.apollo + .mutate<{ group: { delete: boolean } }>({ + mutation: gql` + mutation deleteGroup($id: ID!) { + group { + delete(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data?.group.delete ?? false)); + } + + restore(object: Group): Observable { + return this.apollo + .mutate<{ group: { restore: boolean } }>({ + mutation: gql` + mutation restoreGroup($id: ID!) { + group { + restore(id: $id) + } + } + `, + variables: { + id: object.id, + }, + }) + .pipe( + catchError((err) => { + this.spinner.hide(); + throw err; + }), + ) + .pipe(map((result) => result.data?.group.restore ?? false)); + } + + static provide(): Provider[] { + return [ + { + provide: PageDataService, + useClass: GroupsDataService, + }, + GroupsDataService, + ]; + } +} diff --git a/web/src/app/modules/admin/groups/groups.module.ts b/web/src/app/modules/admin/groups/groups.module.ts new file mode 100644 index 0000000..5fbdb22 --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.module.ts @@ -0,0 +1,43 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { SharedModule } from "src/app/modules/shared/shared.module"; +import { RouterModule, Routes } from "@angular/router"; +import { PermissionGuard } from "src/app/core/guard/permission.guard"; +import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; +import { GroupsPage } from "src/app/modules/admin/groups/groups.page"; +import { GroupFormPageComponent } from "src/app/modules/admin/groups/form-page/group-form-page.component"; +import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; +import { GroupsColumns } from "src/app/modules/admin/groups/groups.columns"; + +const routes: Routes = [ + { + path: "", + title: "Groups", + component: GroupsPage, + children: [ + { + path: "create", + component: GroupFormPageComponent, + canActivate: [PermissionGuard], + data: { + permissions: [PermissionsEnum.apiKeysCreate], + }, + }, + { + path: "edit/:id", + component: GroupFormPageComponent, + canActivate: [PermissionGuard], + data: { + permissions: [PermissionsEnum.apiKeysUpdate], + }, + }, + ], + }, +]; + +@NgModule({ + declarations: [GroupsPage, GroupFormPageComponent], + imports: [CommonModule, SharedModule, RouterModule.forChild(routes)], + providers: [GroupsDataService.provide(), GroupsColumns.provide()], +}) +export class GroupsModule {} diff --git a/web/src/app/modules/admin/groups/groups.page.html b/web/src/app/modules/admin/groups/groups.page.html new file mode 100644 index 0000000..d9a1c72 --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.page.html @@ -0,0 +1,19 @@ + + + diff --git a/web/src/app/modules/admin/groups/groups.page.scss b/web/src/app/modules/admin/groups/groups.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/groups/groups.page.spec.ts b/web/src/app/modules/admin/groups/groups.page.spec.ts new file mode 100644 index 0000000..98eb39d --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.page.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page'; +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 { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service'; + +describe('ApiKeysComponent', () => { + let component: ApiKeysPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ApiKeysPage], + imports: [SharedModule, TranslateModule.forRoot()], + providers: [ + AuthService, + KeycloakService, + ErrorHandlingService, + ToastService, + MessageService, + ConfirmationService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { params: of({}) }, + }, + }, + { + provide: PageDataService, + useClass: ApiKeysDataService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ApiKeysPage); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/web/src/app/modules/admin/groups/groups.page.ts b/web/src/app/modules/admin/groups/groups.page.ts new file mode 100644 index 0000000..f6a835e --- /dev/null +++ b/web/src/app/modules/admin/groups/groups.page.ts @@ -0,0 +1,72 @@ +import { Component } from "@angular/core"; +import { PageBase } from "src/app/core/base/page-base"; +import { ToastService } from "src/app/service/toast.service"; +import { ConfirmationDialogService } from "src/app/service/confirmation-dialog.service"; +import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; +import { Group } from "src/app/model/entities/group"; +import { GroupsDataService } from "src/app/modules/admin/groups/groups.data.service"; +import { GroupsColumns } from "src/app/modules/admin/groups/groups.columns"; + +@Component({ + selector: "app-groups", + templateUrl: "./groups.page.html", + styleUrl: "./groups.page.scss", +}) +export class GroupsPage extends PageBase< + Group, + GroupsDataService, + GroupsColumns +> { + constructor( + private toast: ToastService, + private confirmation: ConfirmationDialogService, + ) { + super(true, { + read: [PermissionsEnum.groups], + create: [PermissionsEnum.groupsCreate], + update: [PermissionsEnum.groupsUpdate], + delete: [PermissionsEnum.groupsDelete], + restore: [PermissionsEnum.groupsDelete], + }); + } + + load(): void { + this.loading = true; + this.dataService + .load(this.filter, this.sort, this.skip, this.take) + .subscribe((result) => { + this.result = result; + this.loading = false; + }); + } + + delete(group: Group): void { + this.confirmation.confirmDialog({ + header: "dialog.delete.header", + message: "dialog.delete.message", + accept: () => { + this.loading = true; + this.dataService.delete(group).subscribe(() => { + this.toast.success("action.deleted"); + this.load(); + }); + }, + messageParams: { entity: group.name }, + }); + } + + restore(group: Group): void { + this.confirmation.confirmDialog({ + header: "dialog.restore.header", + message: "dialog.restore.message", + accept: () => { + this.loading = true; + this.dataService.restore(group).subscribe(() => { + this.toast.success("action.restored"); + this.load(); + }); + }, + messageParams: { entity: group.name }, + }); + } +} diff --git a/web/src/app/service/auth.service.ts b/web/src/app/service/auth.service.ts index 0b9d920..c4a939d 100644 --- a/web/src/app/service/auth.service.ts +++ b/web/src/app/service/auth.service.ts @@ -1,30 +1,29 @@ -import { Injectable } from '@angular/core'; -import { User } from 'src/app/model/auth/user'; -import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; -import { Apollo, gql } from 'apollo-angular'; -import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; -import { KeycloakService } from 'keycloak-angular'; -import { map } from 'rxjs/operators'; -import { Logger } from 'src/app/service/logger.service'; +import { Injectable } from "@angular/core"; +import { User } from "src/app/model/auth/user"; +import { BehaviorSubject, firstValueFrom, Observable } from "rxjs"; +import { Apollo, gql } from "apollo-angular"; +import { PermissionsEnum } from "src/app/model/auth/permissionsEnum"; +import { KeycloakService } from "keycloak-angular"; +import { map } from "rxjs/operators"; +import { Logger } from "src/app/service/logger.service"; -const log = new Logger('AuthService'); +const log = new Logger("AuthService"); @Injectable({ - providedIn: 'root', + providedIn: "root", }) export class AuthService { protected anyPermissionForAdminPage = [ PermissionsEnum.apiKeys, PermissionsEnum.users, PermissionsEnum.roles, - PermissionsEnum.news, ]; user$ = new BehaviorSubject(null); constructor( private apollo: Apollo, - private keycloakService: KeycloakService + private keycloakService: KeycloakService, ) {} private requestUser() { @@ -61,11 +60,11 @@ export class AuthService { permission, }, }) - .pipe(map(result => result.data.userHasPermission)); + .pipe(map((result) => result.data.userHasPermission)); } private userHasAnyPermission( - permissions: PermissionsEnum[] + permissions: PermissionsEnum[], ): Observable { return this.apollo .query<{ userHasAnyPermission: boolean }>({ @@ -78,7 +77,7 @@ export class AuthService { permissions, }, }) - .pipe(map(result => result.data.userHasAnyPermission)); + .pipe(map((result) => result.data.userHasAnyPermission)); } loadUser() { @@ -89,8 +88,8 @@ export class AuthService { return; } - this.requestUser().subscribe(result => { - log.info('User loaded'); + this.requestUser().subscribe((result) => { + log.info("User loaded"); this.user$.next(result.data.user); }); } @@ -112,10 +111,10 @@ export class AuthService { if (!this.user$.value) return false; const userPermissions = this.user$.value.roles - .map(role => (role.permissions ?? []).map(p => p.name)) + .map((role) => (role.permissions ?? []).map((p) => p.name)) .flat(); - return permissions.every(permission => - userPermissions.includes(permission) + return permissions.every((permission) => + userPermissions.includes(permission), ); } @@ -131,7 +130,7 @@ export class AuthService { if (!this.user$.value) return false; const permissions = this.user$.value.roles - .map(role => (role.permissions ?? []).map(p => p.name)) + .map((role) => (role.permissions ?? []).map((p) => p.name)) .flat(); return permissions.includes(permission); } diff --git a/web/src/app/service/error-handling.service.ts b/web/src/app/service/error-handling.service.ts index d035d0b..2fce807 100644 --- a/web/src/app/service/error-handling.service.ts +++ b/web/src/app/service/error-handling.service.ts @@ -29,7 +29,8 @@ export class ErrorHandlingService implements ErrorHandler { this.handleHttpError(error.networkError as HttpErrorResponse); return; } - this.handleHttpError(error); + console.error(error); + // this.handleHttpError(error); } private handleHttpError(e: HttpErrorResponse) { diff --git a/web/src/app/service/sidebar.service.ts b/web/src/app/service/sidebar.service.ts index ef1de6d..60b9310 100644 --- a/web/src/app/service/sidebar.service.ts +++ b/web/src/app/service/sidebar.service.ts @@ -1,99 +1,83 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; -import { MenuElement } from 'src/app/model/view/menu-element'; -import { Router } from '@angular/router'; -import { AuthService } from 'src/app/service/auth.service'; -import { PermissionsEnum } from 'src/app/model/auth/permissionsEnum'; +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; +import {MenuElement} from 'src/app/model/view/menu-element'; +import {AuthService} from 'src/app/service/auth.service'; +import {PermissionsEnum} from 'src/app/model/auth/permissionsEnum'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class SidebarService { - visible$ = new BehaviorSubject(false); - elements$ = new BehaviorSubject([]); + visible$ = new BehaviorSubject(true); + elements$ = new BehaviorSubject([]); - constructor( - private router: Router, - private auth: AuthService - ) { - this.router.events.subscribe(() => { - if (this.router.url.startsWith('/admin')) { - this.show(); - } else { - this.hide(); - } - }); + constructor( + private auth: AuthService + ) { + this.auth.user$.subscribe(user => { + if (user) { + this.setElements().then(); + } + }); + } - this.auth.user$.subscribe(user => { - if (user) { - this.setElements().then(); - } - }); - } + show() { + this.visible$.next(true); + } - show() { - this.visible$.next(true); - } + hide() { + this.visible$.next(false); + } - hide() { - this.visible$.next(false); - } + // trust me, you'll need this async + async setElements() { + const elements: MenuElement[] = [ + { + label: 'common.groups', + icon: 'pi pi-tags', + routerLink: ['/admin/groups'], + }, + { + label: 'common.urls', + icon: 'pi pi-tag', + routerLink: ['/admin/urls'], + }, + await this.groupAdministration(), + ]; + this.elements$.next(elements); + } - // trust me, you'll need this async - async setElements() { - const elements: MenuElement[] = [ - await this.groupPublic(), - await this.groupAdministration(), - ]; - this.elements$.next(elements); - } - - async groupPublic() { - return { - label: 'sidebar.public', - icon: 'pi pi-globe', - expanded: true, - items: [ - { - label: 'common.news', - icon: 'pi pi-file', - routerLink: ['/admin/public/news'], - }, - ], - }; - } - - async groupAdministration() { - return { - label: 'sidebar.administration', - icon: 'pi pi-wrench', - expanded: true, - items: [ - { - label: 'sidebar.users', - icon: 'pi pi-user', - routerLink: ['/admin/administration/users'], - visible: await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.users, - ]), - }, - { - label: 'sidebar.roles', - icon: 'pi pi-user-edit', - routerLink: ['/admin/administration/roles'], - visible: await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.roles, - ]), - }, - { - label: 'sidebar.api_keys', - icon: 'pi pi-key', - routerLink: ['/admin/administration/api-keys'], - visible: await this.auth.hasAnyPermissionLazy([ - PermissionsEnum.apiKeys, - ]), - }, - ], - }; - } + async groupAdministration() { + return { + label: 'sidebar.administration', + icon: 'pi pi-wrench', + expanded: true, + items: [ + { + label: 'sidebar.users', + icon: 'pi pi-user', + routerLink: ['/admin/administration/users'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.users, + ]), + }, + { + label: 'sidebar.roles', + icon: 'pi pi-user-edit', + routerLink: ['/admin/administration/roles'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.roles, + ]), + }, + { + label: 'sidebar.api_keys', + icon: 'pi pi-key', + routerLink: ['/admin/administration/api-keys'], + visible: await this.auth.hasAnyPermissionLazy([ + PermissionsEnum.apiKeys, + ]), + }, + ], + }; + } } diff --git a/web/src/environments/environment.local.ts b/web/src/environments/environment.local.ts index 1d0a1d5..b5e169f 100644 --- a/web/src/environments/environment.local.ts +++ b/web/src/environments/environment.local.ts @@ -4,10 +4,15 @@ export const environment = { production: false, + // auth: { + // url: 'https://auth.sh-edraft.de', + // realm: 'dev', + // clientId: 'open-redirect-client', + // }, auth: { - url: 'https://auth.sh-edraft.de', - realm: 'dev', - clientId: 'open-redirect-client', + url: 'https://keycloak.maxlan.de', + realm: 'develop', + clientId: 'lan-maestro-client-local', }, api: { url: 'http://localhost:5000/api',