diff --git a/src/angular/frontend/src/app/app.routes.ts b/src/angular/frontend/src/app/app.routes.ts index 806bd80..17878ff 100644 --- a/src/angular/frontend/src/app/app.routes.ts +++ b/src/angular/frontend/src/app/app.routes.ts @@ -92,56 +92,32 @@ export const routes: Routes = [ tenant: tenantResolver, title: tenantNameResolver, }, + }, + { + title: 'Subject TODO', + path: 'subject/:subjectId', + component: HomeComponent + }, + { + path: ':id', + resolve: { + title: tenantNameResolver, + }, children: [ - { - title: 'Subject TODO', - path: 'subject/:subjectId', - component: HomeComponent - }, { title: 'Apps', - path: 'apps', - component: HomeComponent, + path: 'app', children: [ { title: 'App TODO', - path: ':id', - component: HomeComponent, - children: [ - { - title: 'Groups', - path: 'groups', - component: HomeComponent, - }, - { - title: 'Roles', - path: 'roles', - component: HomeComponent, - }, - { - title: 'Authorities', - path: 'authorities', - component: HomeComponent, - }, - ] + path: ':appId', + component: HomeComponent } - ] - }, - { - title: 'Groups', - path: 'groups', - component: HomeComponent, - }, - { - title: 'Roles', - path: 'roles', - component: HomeComponent, - }, - { - title: 'Authorities', - path: 'authorities', - component: HomeComponent, - }, + ], + data: { + breadcrumbUrl: '?tab=apps' + } + } ] }, ] diff --git a/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/authority-dto.ts b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/authority-dto.ts new file mode 100644 index 0000000..ea5d774 --- /dev/null +++ b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/authority-dto.ts @@ -0,0 +1,12 @@ +import {Identifiable} from '../../../../components/list/list.component'; + +export interface AuthorityDto extends Identifiable { + id: string; + tenantId: string; + name: string; + categoryPath: string; + description: string; + type: string; + visibility: string; + appId?: string; +} diff --git a/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/group-dto.ts b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/group-dto.ts new file mode 100644 index 0000000..d7b740b --- /dev/null +++ b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/group-dto.ts @@ -0,0 +1,3 @@ +import {AuthorityDto} from './authority-dto'; + +export interface GroupDto extends AuthorityDto {} diff --git a/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/role-dto.ts b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/role-dto.ts new file mode 100644 index 0000000..dffd6ae --- /dev/null +++ b/src/angular/frontend/src/app/clients/gandalf/mithrandir/dtos/role-dto.ts @@ -0,0 +1,3 @@ +import {AuthorityDto} from './authority-dto'; + +export interface RoleDto extends AuthorityDto {} diff --git a/src/angular/frontend/src/app/clients/gandalf/mithrandir/tenant/tenant.service.ts b/src/angular/frontend/src/app/clients/gandalf/mithrandir/tenant/tenant.service.ts index 737e3d5..0da8bd5 100644 --- a/src/angular/frontend/src/app/clients/gandalf/mithrandir/tenant/tenant.service.ts +++ b/src/angular/frontend/src/app/clients/gandalf/mithrandir/tenant/tenant.service.ts @@ -3,6 +3,9 @@ import {lastValueFrom, Observable, of} from "rxjs"; import {PalantirService} from '../palantir.service'; import {AppListDto} from '../dtos/app-list-dto'; import {TenantGridViewDto} from '../dtos/tenant-grid-view-dto'; +import {AuthorityDto} from '../dtos/authority-dto'; +import {RoleDto} from '../dtos/role-dto'; +import {GroupDto} from '../dtos/group-dto'; @Injectable({ providedIn: 'root' @@ -39,4 +42,28 @@ export class TenantService extends PalantirService { getTenantAppsAsync(id: string, onError?: (error: any) => void): Promise { return lastValueFrom(this.getTenantApps$(id, onError)); } + + getTenantAuthorities$(id: string, onError?: (error: any) => void): Observable { + return this.handleRequest(this.http.get(this.baseUrl + `/${id}/authorities`), onError); + } + + getTenantAuthoritiesAsync(id: string, onError?: (error: any) => void): Promise { + return lastValueFrom(this.getTenantAuthorities$(id, onError)); + } + + getTenantRoles$(id: string, onError?: (error: any) => void): Observable { + return this.handleRequest(this.http.get(this.baseUrl + `/${id}/roles`), onError); + } + + getTenantRolesAsync(id: string, onError?: (error: any) => void): Promise { + return lastValueFrom(this.getTenantRoles$(id, onError)); + } + + getTenantGroups$(id: string, onError?: (error: any) => void): Observable { + return this.handleRequest(this.http.get(this.baseUrl + `/${id}/groups`), onError); + } + + getTenantGroupsAsync(id: string, onError?: (error: any) => void): Promise { + return lastValueFrom(this.getTenantGroups$(id, onError)); + } } diff --git a/src/angular/frontend/src/app/components/link/link.component.ts b/src/angular/frontend/src/app/components/link/link.component.ts index fb88ac6..11d2894 100644 --- a/src/angular/frontend/src/app/components/link/link.component.ts +++ b/src/angular/frontend/src/app/components/link/link.component.ts @@ -1,5 +1,5 @@ import {Component, inject, input} from '@angular/core'; -import {Router} from '@angular/router'; +import {ActivatedRoute, Router} from '@angular/router'; @Component({ selector: 'app-link', @@ -13,11 +13,39 @@ export class LinkComponent { public external = input(false); private router = inject(Router); + private route = inject(ActivatedRoute); click($event: PointerEvent) { + if (!this.external()) { $event.preventDefault(); - this.router.navigate([this.href()]).then(); + + const link = this.href(); + const split = link.split('?'); + const path = split[0] + const queryParamsStrings = split[1]?.split('&'); + + const queryParams: {[key: string]: string} = {}; + + if (queryParamsStrings) { + for (const queryParamString of queryParamsStrings) { + const splitQueryParam = queryParamString.split('='); + const key = splitQueryParam[0] as string; + const value = splitQueryParam[1]; + + if (key && value) { + queryParams[key] = value; + } + } + } + + this.router.navigate([path], { + relativeTo: this.route, + queryParams: queryParams, + queryParamsHandling: 'merge', + skipLocationChange: false, + replaceUrl: false + }).then(); } } diff --git a/src/angular/frontend/src/app/components/list/list.component.html b/src/angular/frontend/src/app/components/list/list.component.html new file mode 100644 index 0000000..9c31baf --- /dev/null +++ b/src/angular/frontend/src/app/components/list/list.component.html @@ -0,0 +1,13 @@ +@if (loading()) { + Loading... +} @else { + + @if (items().length === 0) { + List is empty :( + } + @else { + @for (item of items(); track item.id) { + + } + } +} diff --git a/src/angular/frontend/src/app/components/list/list.component.scss b/src/angular/frontend/src/app/components/list/list.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/angular/frontend/src/app/components/list/list.component.spec.ts b/src/angular/frontend/src/app/components/list/list.component.spec.ts new file mode 100644 index 0000000..b602c86 --- /dev/null +++ b/src/angular/frontend/src/app/components/list/list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ListComponent } from './list.component'; + +describe('ListComponent', () => { + let component: ListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ListComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/angular/frontend/src/app/components/list/list.component.ts b/src/angular/frontend/src/app/components/list/list.component.ts new file mode 100644 index 0000000..259baf9 --- /dev/null +++ b/src/angular/frontend/src/app/components/list/list.component.ts @@ -0,0 +1,25 @@ +import {Component, input, TemplateRef} from '@angular/core'; +import {PanelComponent} from '../panel/panel.component'; +import {NgTemplateOutlet} from '@angular/common'; + +export interface Identifiable { + id: string; +} + +@Component({ + selector: 'app-list', + imports: [ + PanelComponent, + NgTemplateOutlet + ], + templateUrl: './list.component.html', + styleUrl: './list.component.scss', +}) +export class ListComponent { + + public items = input.required(); + public itemTemplate = input.required>(); + public loading = input(false); + public metadata = input(); + +} diff --git a/src/angular/frontend/src/app/components/login/login.component.ts b/src/angular/frontend/src/app/components/login/login.component.ts index af44867..dc5d06d 100644 --- a/src/angular/frontend/src/app/components/login/login.component.ts +++ b/src/angular/frontend/src/app/components/login/login.component.ts @@ -21,7 +21,7 @@ export class LoginComponent { protected loginFormGroup = new FormGroup({ usernameOrEmail: new FormControl('housemaster'), - password: new FormControl('kR0pNCspBKx8lOzAIch5'), + password: new FormControl('W33PWO6IIRpkc6VSBIY0'), keepSignedIn: new FormControl(false), }); diff --git a/src/angular/frontend/src/app/components/shell/shell.component.ts b/src/angular/frontend/src/app/components/shell/shell.component.ts index e5c5bd1..b759418 100644 --- a/src/angular/frontend/src/app/components/shell/shell.component.ts +++ b/src/angular/frontend/src/app/components/shell/shell.component.ts @@ -1,6 +1,6 @@ import {Component, computed, OnInit, signal} from '@angular/core'; import {NavProfileComponent} from './nav-profile/nav-profile.component'; -import {ActivatedRoute, NavigationEnd, Route, Router, RouterLink} from '@angular/router'; +import {ActivatedRoute, NavigationEnd, Route, Router} from '@angular/router'; import {IconComponent} from '../icon/icon.component'; import {DrawerComponent} from './drawer/drawer.component'; import {filter} from 'rxjs'; @@ -13,7 +13,6 @@ import {LinkComponent} from '../link/link.component'; NavProfileComponent, IconComponent, DrawerComponent, - RouterLink, NgClass, LinkComponent ], @@ -86,14 +85,27 @@ export class ShellComponent implements OnInit { const child = children[0]; const routeURL = child.snapshot.url.map(segment => segment.path).join('/'); - if (routeURL) { + const overrideUrl = child.snapshot.data['breadcrumbUrl']; + + if (overrideUrl) { + + if (overrideUrl.startsWith('?')) { + url += overrideUrl; + } else { + url += !url.endsWith('/') ? `/${overrideUrl}` : `${overrideUrl}`; + } + + } + else if (routeURL) { url += !url.endsWith('/') ? `/${routeURL}` : `${routeURL}`; } const label = - child.snapshot.data['title'] || - child.snapshot.routeConfig?.title || - ''; + child.snapshot.routeConfig?.title + || child.snapshot.data['title'] + || ''; + + if (label) { breadcrumbs.push({ label, url }); diff --git a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.html b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.html index 38801e9..6ca7444 100644 --- a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.html +++ b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.html @@ -9,15 +9,30 @@ - {{tab.name}} - content + - {{tab.name}} - content + - {{tab.name}} - content + + + + + {{ item.name }} + + @if (item.description) { + | + {{ item.description }} + } + + + + + + diff --git a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.scss b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.scss index e69de29..78175eb 100644 --- a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.scss +++ b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.scss @@ -0,0 +1,14 @@ +:host { + .authority-item { + display: flex; + align-items: center; + + .description, .spacer { + color: var(--neutral-40); + } + + .actions { + margin-left: auto; + } + } +} diff --git a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.ts b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.ts index ebf0e21..f316b50 100644 --- a/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.ts +++ b/src/angular/frontend/src/app/components/tenant/tenant-detail/tenant-detail.component.ts @@ -1,9 +1,15 @@ import {Component, inject, signal} from '@angular/core'; import {TabGroup, TabGroupComponent} from '../../tab-group/tab-group.component'; import {ActiveTabDirective} from '../../tab-group/active-tab.directive'; -import {ActivatedRoute, Router} from '@angular/router'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {SubjectListComponent} from '../../subject/subject-list/subject-list.component'; import {AppListComponent} from '../../app/app-list/app-list.component'; +import {ListComponent} from '../../list/list.component'; +import {PanelComponent} from '../../panel/panel.component'; +import {GroupDto} from '../../../clients/gandalf/mithrandir/dtos/group-dto'; +import {RoleDto} from '../../../clients/gandalf/mithrandir/dtos/role-dto'; +import {AuthorityDto} from '../../../clients/gandalf/mithrandir/dtos/authority-dto'; +import {TenantService} from '../../../clients/gandalf/mithrandir/tenant/tenant.service'; @Component({ selector: 'app-tenant-detail', @@ -11,7 +17,10 @@ import {AppListComponent} from '../../app/app-list/app-list.component'; TabGroupComponent, ActiveTabDirective, SubjectListComponent, - AppListComponent + AppListComponent, + ListComponent, + PanelComponent, + RouterLink ], templateUrl: './tenant-detail.component.html', styleUrl: './tenant-detail.component.scss', @@ -44,8 +53,16 @@ export class TenantDetailComponent { protected activeTabId = signal(null) protected tenantId = signal(null) + protected groups = signal([]); + protected groupsLoading = signal(true); + protected roles = signal([]); + protected rolesLoading = signal(true); + protected authorities = signal([]); + protected authoritiesLoading = signal(true); + private route = inject(ActivatedRoute); private router = inject(Router) + private tenantService = inject(TenantService); constructor() { const routeSnapshot = this.route.snapshot; @@ -65,5 +82,35 @@ export class TenantDetailComponent { skipLocationChange: false, replaceUrl: true }); + + const tenantId = this.tenantId(); + + if (!tenantId) { + return; + } + + switch (tab.id) { + case 'groups': + if (this.groupsLoading()) { + const groups = await this.tenantService.getTenantGroupsAsync(tenantId); + this.groups.set(groups); + this.groupsLoading.set(false); + } + break; + case 'roles': + if (this.rolesLoading()) { + const roles = await this.tenantService.getTenantRolesAsync(tenantId); + this.roles.set(roles); + this.rolesLoading.set(false); + } + break; + case 'authorities': + if (this.authoritiesLoading()) { + const authorities = await this.tenantService.getTenantAuthoritiesAsync(tenantId); + this.authorities.set(authorities); + this.authoritiesLoading.set(false); + } + break; + } } } diff --git a/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/statistic.xml b/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/statistic.xml new file mode 100644 index 0000000..adecab4 --- /dev/null +++ b/src/dotnet/.idea/.idea.Suspectus.Gandalf/.idea/statistic.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs index af64ea6..8140468 100644 --- a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs @@ -5,8 +5,11 @@ using Microsoft.EntityFrameworkCore; using Suspectus.Gandalf.Palantir.Abstractions; using Suspectus.Gandalf.Palantir.Data.Database; using Suspectus.Gandalf.Palantir.Data.Dto.App; +using Suspectus.Gandalf.Palantir.Data.Dto.Group; +using Suspectus.Gandalf.Palantir.Data.Dto.Role; using Suspectus.Gandalf.Palantir.Data.Dto.Tenant; using Suspectus.Gandalf.Palantir.Data.Entities.Base; +using Suspectus.Gandalf.Palantir.Data.Entities.Security.Permission.Data; namespace Suspectus.Gandalf.Palantir.Api.Controllers; @@ -121,4 +124,130 @@ public class TenantController : ControllerBase return Ok(dtos); } + + [HttpGet("{tenantIdHash}/groups")] + public async Task GetTenantGroups(InvokerContext invokerContext, string tenantIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(tenantIdHash, out var tenantId)) + { + return BadRequest(); + } + + var tenantExists = await _context.Tenants.AnyAsync(x => x.Id == tenantId, cancellationToken); + + if (!tenantExists) + { + return NotFound(); + } + + var relationExists = await _context.TenantSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Groups + .Where(x => x.TenantId == tenantId && x.Type == AuthorityType.Tenant) + .ToListAsync(cancellationToken); + + var dtos = groups.Select(x => new GroupDto + { + Id = _hashids.EncodeLong(x.Id!.Value), + Name = x.Name, + Visibility = x.Visibility, + TenantId = _hashids.EncodeLong(x.TenantId), + CategoryPath = x.CategoryPath, + Description = x.Description, + Type = x.Type + }); + + return Ok(dtos); + } + + [HttpGet("{tenantIdHash}/roles")] + public async Task GetTenantRoles(InvokerContext invokerContext, string tenantIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(tenantIdHash, out var tenantId)) + { + return BadRequest(); + } + + var tenantExists = await _context.Tenants.AnyAsync(x => x.Id == tenantId, cancellationToken); + + if (!tenantExists) + { + return NotFound(); + } + + var relationExists = await _context.TenantSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Roles + .Where(x => x.TenantId == tenantId && x.Type == AuthorityType.Tenant) + .ToListAsync(cancellationToken); + + var dtos = groups.Select(x => new RoleDto + { + Id = _hashids.EncodeLong(x.Id!.Value), + Name = x.Name, + Visibility = x.Visibility, + TenantId = _hashids.EncodeLong(x.TenantId), + CategoryPath = x.CategoryPath, + Description = x.Description, + Type = x.Type + }); + + return Ok(dtos); + } + + [HttpGet("{tenantIdHash}/authorities")] + public async Task GetTenantAuthorities(InvokerContext invokerContext, string tenantIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(tenantIdHash, out var tenantId)) + { + return BadRequest(); + } + + var tenantExists = await _context.Tenants.AnyAsync(x => x.Id == tenantId, cancellationToken); + + if (!tenantExists) + { + return NotFound(); + } + + var relationExists = await _context.TenantSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Authorities + .Where(x => x.TenantId == tenantId && x.Type == AuthorityType.Tenant) + .ToListAsync(cancellationToken); + + var dtos = groups.Select(x => new GroupDto + { + Id = _hashids.EncodeLong(x.Id!.Value), + Name = x.Name, + Visibility = x.Visibility, + TenantId = _hashids.EncodeLong(x.TenantId), + CategoryPath = x.CategoryPath, + Description = x.Description, + Type = x.Type + }); + + return Ok(dtos); + } } \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Authority/AuthorityDto.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Authority/AuthorityDto.cs new file mode 100644 index 0000000..5e6e06e --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Authority/AuthorityDto.cs @@ -0,0 +1,16 @@ +using Suspectus.Gandalf.Palantir.Data.Entities.Base; +using Suspectus.Gandalf.Palantir.Data.Entities.Security.Permission.Data; + +namespace Suspectus.Gandalf.Palantir.Data.Dto.Authority; + +public class AuthorityDto +{ + public required string Id { get; set; } + public required string TenantId { get; set; } + public required string Name { get; set; } + public required string CategoryPath { get; set; } + public required string? Description { get; set; } + public required AuthorityType Type { get; set; } + public required EntityVisibility Visibility { get; set; } + public string? AppId { get; set; } +} \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Group/GroupDto.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Group/GroupDto.cs new file mode 100644 index 0000000..9a8d707 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Group/GroupDto.cs @@ -0,0 +1,5 @@ +using Suspectus.Gandalf.Palantir.Data.Dto.Authority; + +namespace Suspectus.Gandalf.Palantir.Data.Dto.Group; + +public class GroupDto : AuthorityDto; \ No newline at end of file diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Role/RoleDto.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Role/RoleDto.cs new file mode 100644 index 0000000..4e18131 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Data/Dto/Role/RoleDto.cs @@ -0,0 +1,6 @@ +using Suspectus.Gandalf.Palantir.Data.Dto.Authority; +using Suspectus.Gandalf.Palantir.Data.Dto.Group; + +namespace Suspectus.Gandalf.Palantir.Data.Dto.Role; + +public class RoleDto : AuthorityDto; \ No newline at end of file