diff --git a/src/angular/frontend/src/app/app.routes.ts b/src/angular/frontend/src/app/app.routes.ts index 17878ff..cb57b58 100644 --- a/src/angular/frontend/src/app/app.routes.ts +++ b/src/angular/frontend/src/app/app.routes.ts @@ -9,6 +9,8 @@ import {TenantGridComponent} from './components/tenant/tenant-grid/tenant-grid.c import {OutletComponent} from './components/outlet/outlet.component'; import {tenantNameResolver, tenantResolver} from './resolvers/tenantResolver'; import {TenantDetailComponent} from './components/tenant/tenant-detail/tenant-detail.component'; +import {appNameResolver} from './resolvers/app.resolver'; +import {AppDetailComponent} from './components/app/app-detail/app-detail.component'; export const routes: Routes = [ { @@ -109,9 +111,11 @@ export const routes: Routes = [ path: 'app', children: [ { - title: 'App TODO', path: ':appId', - component: HomeComponent + component: AppDetailComponent, + resolve: { + title: appNameResolver + } } ], data: { diff --git a/src/angular/frontend/src/app/clients/gandalf/mithrandir/app/app.service.ts b/src/angular/frontend/src/app/clients/gandalf/mithrandir/app/app.service.ts new file mode 100644 index 0000000..09024a9 --- /dev/null +++ b/src/angular/frontend/src/app/clients/gandalf/mithrandir/app/app.service.ts @@ -0,0 +1,60 @@ +import {Injectable} from "@angular/core"; +import {lastValueFrom, Observable, of} from "rxjs"; +import {PalantirService} from '../palantir.service'; +import {AppListDto} from '../dtos/app-list-dto'; +import {AuthorityDto} from '../dtos/authority-dto'; +import {RoleDto} from '../dtos/role-dto'; +import {GroupDto} from '../dtos/group-dto'; + +@Injectable({ + providedIn: 'root' +}) +export class AppService extends PalantirService { + + protected baseUrl = this.base + '/api/app'; + + tenants$(onError?: (error: any) => void): Observable { + return this.handleRequest(this.http.get(this.baseUrl), onError); + } + + tenantsAsync(onError?: (error: any) => void): Promise { + return lastValueFrom(this.tenants$(onError)); + } + + getTenant$(id: string | null, onError?: (error: any) => void): Observable { + + if (id === null) { + return of(null); + } + + return this.handleRequest(this.http.get(this.baseUrl + `/${id}`), onError); + } + + getTenantAsync(id: string | null, onError?: (error: any) => void): Promise { + return lastValueFrom(this.getTenant$(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/app/app-detail/app-detail.component.html b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.html new file mode 100644 index 0000000..6e6cf7b --- /dev/null +++ b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + {{ item.name }} + + @if (item.description) { + | + {{ item.description }} + } + + + + + + diff --git a/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.scss b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.scss new file mode 100644 index 0000000..78175eb --- /dev/null +++ b/src/angular/frontend/src/app/components/app/app-detail/app-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/app/app-detail/app-detail.component.spec.ts b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.spec.ts new file mode 100644 index 0000000..5cb4892 --- /dev/null +++ b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppDetailComponent } from './app-detail.component'; + +describe('AppDetailComponent', () => { + let component: AppDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AppDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.ts b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.ts new file mode 100644 index 0000000..1b3933d --- /dev/null +++ b/src/angular/frontend/src/app/components/app/app-detail/app-detail.component.ts @@ -0,0 +1,108 @@ +import {Component, inject, signal} from '@angular/core'; +import {TabGroup, TabGroupComponent} from '../../tab-group/tab-group.component'; +import {SubjectListComponent} from '../../subject/subject-list/subject-list.component'; +import {ListComponent} from '../../list/list.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 {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {TenantService} from '../../../clients/gandalf/mithrandir/tenant/tenant.service'; +import {PanelComponent} from '../../panel/panel.component'; +import {AppService} from '../../../clients/gandalf/mithrandir/app/app.service'; +import {ActiveTabDirective} from '../../tab-group/active-tab.directive'; + +@Component({ + selector: 'app-app-detail', + imports: [ + TabGroupComponent, + SubjectListComponent, + ListComponent, + PanelComponent, + RouterLink, + ActiveTabDirective + ], + templateUrl: './app-detail.component.html', + styleUrl: './app-detail.component.scss', +}) +export class AppDetailComponent { + + protected tabs = [ + { + id: "groups", + name: "Groups" + } satisfies TabGroup, + { + id: "roles", + name: "Roles" + } satisfies TabGroup, + { + id: "authorities", + name: "Authorities" + } satisfies TabGroup + ] + + protected activeTabId = signal(null) + protected appId = 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 appService = inject(AppService); + + constructor() { + const routeSnapshot = this.route.snapshot; + const appId = routeSnapshot.paramMap.get('appId'); + const tabId = routeSnapshot.queryParamMap.get('tab'); + this.activeTabId.set(tabId) + this.appId.set(appId); + } + + async tabChanged(tab: TabGroup) { + await this.router.navigate([], { + relativeTo: this.route, + queryParams: { + tab: tab.id + }, + queryParamsHandling: 'merge', + skipLocationChange: false, + replaceUrl: true + }); + + const tenantId = this.appId(); + + if (!tenantId) { + return; + } + + switch (tab.id) { + case 'groups': + if (this.groupsLoading()) { + const groups = await this.appService.getTenantGroupsAsync(tenantId); + this.groups.set(groups); + this.groupsLoading.set(false); + } + break; + case 'roles': + if (this.rolesLoading()) { + const roles = await this.appService.getTenantRolesAsync(tenantId); + this.roles.set(roles); + this.rolesLoading.set(false); + } + break; + case 'authorities': + if (this.authoritiesLoading()) { + const authorities = await this.appService.getTenantAuthoritiesAsync(tenantId); + this.authorities.set(authorities); + this.authoritiesLoading.set(false); + } + break; + } + } + +} diff --git a/src/angular/frontend/src/app/components/list/list.component.scss b/src/angular/frontend/src/app/components/list/list.component.scss index e69de29..1556b2c 100644 --- a/src/angular/frontend/src/app/components/list/list.component.scss +++ b/src/angular/frontend/src/app/components/list/list.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/src/angular/frontend/src/app/components/shell/shell.component.html b/src/angular/frontend/src/app/components/shell/shell.component.html index 21e2043..ef2e3d2 100644 --- a/src/angular/frontend/src/app/components/shell/shell.component.html +++ b/src/angular/frontend/src/app/components/shell/shell.component.html @@ -20,7 +20,7 @@ / - @for (crumb of breadcrumbs(); let last = $last; track crumb.label) { + @for (crumb of breadcrumbs(); let last = $last; track crumb.id) { @if (crumb.url && !last) { {{ crumb.label }} 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 b759418..d3c5e92 100644 --- a/src/angular/frontend/src/app/components/shell/shell.component.ts +++ b/src/angular/frontend/src/app/components/shell/shell.component.ts @@ -69,7 +69,8 @@ export class ShellComponent implements OnInit { .filter(r => r.path === '') .flatMap(r => r.children ?? []) .filter(r => r.data?.['showInNav']) - .map(r => ({ + .map((r, id) => ({ + id, label: r.data?.['title'] || r.title || '', url: '/' + (r.path ?? '') })); @@ -105,10 +106,8 @@ export class ShellComponent implements OnInit { || child.snapshot.data['title'] || ''; - - if (label) { - breadcrumbs.push({ label, url }); + breadcrumbs.push({ id: breadcrumbs.length, label, url}); } return this.buildBreadcrumbs(child, url, breadcrumbs); @@ -116,6 +115,7 @@ export class ShellComponent implements OnInit { } export class Breadcrumb { + id: number = 0; label: string = 'undefined'; url: string = ''; } diff --git a/src/angular/frontend/src/app/resolvers/app.resolver.ts b/src/angular/frontend/src/app/resolvers/app.resolver.ts new file mode 100644 index 0000000..20cbce2 --- /dev/null +++ b/src/angular/frontend/src/app/resolvers/app.resolver.ts @@ -0,0 +1,21 @@ +import {ResolveFn} from '@angular/router'; +import {inject} from '@angular/core'; +import {map} from 'rxjs'; +import {AppListDto} from '../clients/gandalf/mithrandir/dtos/app-list-dto'; +import {AppService} from '../clients/gandalf/mithrandir/app/app.service'; + +export const appResolver: ResolveFn = (route, state) => { + const appService = inject(AppService); + + const id = route.paramMap.get('appId') + + return appService.getTenant$(id); +}; + +export const appNameResolver: ResolveFn = (route, state) => { + const appService = inject(AppService); + + const id = route.paramMap.get('appId') + + return appService.getTenant$(id).pipe(map(app => app?.name ?? 'Unnamed App')); +}; diff --git a/src/angular/frontend/src/app/resolvers/subject.resolver.spec.ts b/src/angular/frontend/src/app/resolvers/subject.resolver.spec.ts deleted file mode 100644 index 189ef40..0000000 --- a/src/angular/frontend/src/app/resolvers/subject.resolver.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { ResolveFn } from '@angular/router'; - -import { subjectResolver } from './subject.resolver'; - -describe('subjectResolver', () => { - const executeResolver: ResolveFn = (...resolverParameters) => - TestBed.runInInjectionContext(() => subjectResolver(...resolverParameters)); - - beforeEach(() => { - TestBed.configureTestingModule({}); - }); - - it('should be created', () => { - expect(executeResolver).toBeTruthy(); - }); -}); diff --git a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs new file mode 100644 index 0000000..7b17dd7 --- /dev/null +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/AppController.cs @@ -0,0 +1,210 @@ +using HashidsNet; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +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; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class AppController : ControllerBase +{ + private readonly ApplicationContext _context; + private readonly IHashids _hashids; + + public AppController(ApplicationContext context, IHashids hashids) + { + _context = context; + _hashids = hashids; + } + + [HttpGet] + public async Task Get(InvokerContext invokerContext, CancellationToken cancellationToken) + { + var appEntities = await _context.Subjects + .Where(x => x.Id!.Value == invokerContext.Invoker!.SubjectId) + .SelectMany(x => x.Apps) + .ToListAsync(cancellationToken); + + var dtos = appEntities.Select(x => new AppListDto + { + Id = _hashids.EncodeLong(x.Id!.Value), + Name = x.Name, + Visibility = x.Visibility + }); + + return Ok(dtos); + } + + [HttpGet("{idHash}")] + public async Task GetById(string idHash, InvokerContext invokerContext, CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(idHash, out var appId)) + { + return BadRequest(); + } + + var appEntity = await _context.Apps.SingleOrDefaultAsync(x => x.Id == appId, cancellationToken); + + if (appEntity is null) + { + return NotFound(); + } + + var userHasRelation = await _context.AppSubjectRelations.AnyAsync( + x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.AppId == appId, + cancellationToken: cancellationToken); + + if (!userHasRelation) + { + return Forbid(); + } + + var dto = new AppListDto + { + Id = _hashids.EncodeLong(appEntity.Id!.Value), + Name = appEntity.Name, + Visibility = appEntity.Visibility + }; + + return Ok(dto); + } + + [HttpGet("{appIdHash}/groups")] + public async Task GetTenantGroups(InvokerContext invokerContext, string appIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(appIdHash, out var appId)) + { + return BadRequest(); + } + + var appExists = await _context.Apps.AnyAsync(x => x.Id == appId, cancellationToken); + + if (!appExists) + { + return NotFound(); + } + + var relationExists = await _context.AppSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.AppId == appId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Groups + .Where(x => x.AppId == appId && x.Type == AuthorityType.App) + .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), + AppId = _hashids.EncodeLong(x.AppId!.Value), + CategoryPath = x.CategoryPath, + Description = x.Description, + Type = x.Type + }); + + return Ok(dtos); + } + + [HttpGet("{appIdHash}/roles")] + public async Task GetTenantRoles(InvokerContext invokerContext, string appIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(appIdHash, out var appId)) + { + return BadRequest(); + } + + var appExists = await _context.Apps.AnyAsync(x => x.Id == appId, cancellationToken); + + if (!appExists) + { + return NotFound(); + } + + var relationExists = await _context.AppSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.AppId == appId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Roles + .Where(x => x.AppId == appId && x.Type == AuthorityType.App) + .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), + AppId = _hashids.EncodeLong(x.AppId!.Value), + CategoryPath = x.CategoryPath, + Description = x.Description, + Type = x.Type + }); + + return Ok(dtos); + } + + [HttpGet("{appIdHash}/authorities")] + public async Task GetTenantAuthorities(InvokerContext invokerContext, string appIdHash, + CancellationToken cancellationToken) + { + if (!_hashids.TryDecodeSingleLong(appIdHash, out var appId)) + { + return BadRequest(); + } + + var appExists = await _context.Apps.AnyAsync(x => x.Id == appId, cancellationToken); + + if (!appExists) + { + return NotFound(); + } + + var relationExists = await _context.AppSubjectRelations + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.AppId == appId, cancellationToken); + + if (!relationExists) + { + return Forbid(); + } + + var groups = await _context.Authorities + .Where(x => x.AppId == appId && x.Type == AuthorityType.App) + .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), + AppId = _hashids.EncodeLong(x.AppId!.Value), + 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.Api/Controllers/TenantController.cs b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs index 8140468..14fb5f7 100644 --- a/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs +++ b/src/dotnet/Suspectus.Gandalf.Palantir.Api/Controllers/TenantController.cs @@ -142,7 +142,7 @@ public class TenantController : ControllerBase } var relationExists = await _context.TenantSubjectRelations - .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.TenantId == tenantId, cancellationToken); if (!relationExists) { @@ -184,7 +184,7 @@ public class TenantController : ControllerBase } var relationExists = await _context.TenantSubjectRelations - .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.TenantId == tenantId, cancellationToken); if (!relationExists) { @@ -226,7 +226,7 @@ public class TenantController : ControllerBase } var relationExists = await _context.TenantSubjectRelations - .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId, cancellationToken); + .AnyAsync(x => x.SubjectId == invokerContext.Invoker!.SubjectId && x.TenantId == tenantId, cancellationToken); if (!relationExists) {