add app detail

This commit is contained in:
Christian Werner 2025-10-31 20:30:55 +01:00
parent 548d874221
commit afc7662a62
13 changed files with 485 additions and 27 deletions

View File

@ -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: {

View File

@ -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<AppListDto[]> {
return this.handleRequest(this.http.get<AppListDto[]>(this.baseUrl), onError);
}
tenantsAsync(onError?: (error: any) => void): Promise<AppListDto[]> {
return lastValueFrom(this.tenants$(onError));
}
getTenant$(id: string | null, onError?: (error: any) => void): Observable<AppListDto | null> {
if (id === null) {
return of(null);
}
return this.handleRequest(this.http.get<AppListDto | null>(this.baseUrl + `/${id}`), onError);
}
getTenantAsync(id: string | null, onError?: (error: any) => void): Promise<AppListDto | null> {
return lastValueFrom(this.getTenant$(id, onError));
}
getTenantAuthorities$(id: string, onError?: (error: any) => void): Observable<AuthorityDto[]> {
return this.handleRequest(this.http.get<AuthorityDto[]>(this.baseUrl + `/${id}/authorities`), onError);
}
getTenantAuthoritiesAsync(id: string, onError?: (error: any) => void): Promise<AuthorityDto[]> {
return lastValueFrom(this.getTenantAuthorities$(id, onError));
}
getTenantRoles$(id: string, onError?: (error: any) => void): Observable<RoleDto[]> {
return this.handleRequest(this.http.get<RoleDto[]>(this.baseUrl + `/${id}/roles`), onError);
}
getTenantRolesAsync(id: string, onError?: (error: any) => void): Promise<RoleDto[]> {
return lastValueFrom(this.getTenantRoles$(id, onError));
}
getTenantGroups$(id: string, onError?: (error: any) => void): Observable<GroupDto[]> {
return this.handleRequest(this.http.get<GroupDto[]>(this.baseUrl + `/${id}/groups`), onError);
}
getTenantGroupsAsync(id: string, onError?: (error: any) => void): Promise<GroupDto[]> {
return lastValueFrom(this.getTenantGroups$(id, onError));
}
}

View File

@ -0,0 +1,30 @@
<app-tab-group [tabs]="tabs" (tabChanged)="tabChanged($event)" [activeTabId]="activeTabId()">
<ng-template tabId="groups" let-tab>
<app-list [itemTemplate]="authorityItem" [items]="groups()" [loading]="groupsLoading()" metadata="group"></app-list>
</ng-template>
<ng-template tabId="roles" let-tab>
<app-list [itemTemplate]="authorityItem" [items]="roles()" [loading]="rolesLoading()" metadata="role"></app-list>
</ng-template>
<ng-template tabId="authorities" let-tab>
<app-list [itemTemplate]="authorityItem" [items]="authorities()" [loading]="authoritiesLoading()" metadata="authority"></app-list>
</ng-template>
</app-tab-group>
<ng-template #authorityItem let-item let-metadata="metadata">
<app-panel class="neutral-80 authority-item">
<span class="title">{{ item.name }}</span>
@if (item.description) {
<span class="spacer">|</span>
<span class="description">{{ item.description }}</span>
}
<span class="actions">
<button [routerLink]="metadata + '/' + item.id" class="primary outline">View Details</button>
</span>
</app-panel>
</ng-template>

View File

@ -0,0 +1,14 @@
:host {
.authority-item {
display: flex;
align-items: center;
.description, .spacer {
color: var(--neutral-40);
}
.actions {
margin-left: auto;
}
}
}

View File

@ -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<AppDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppDetailComponent]
})
.compileComponents();
fixture = TestBed.createComponent(AppDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<string | null>(null)
protected appId = signal<string | null>(null)
protected groups = signal<GroupDto[]>([]);
protected groupsLoading = signal<boolean>(true);
protected roles = signal<RoleDto[]>([]);
protected rolesLoading = signal<boolean>(true);
protected authorities = signal<AuthorityDto[]>([]);
protected authoritiesLoading = signal<boolean>(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;
}
}
}

View File

@ -0,0 +1,5 @@
:host {
display: flex;
flex-direction: column;
gap: 1rem;
}

View File

@ -20,7 +20,7 @@
<span> / </span>
@for (crumb of breadcrumbs(); let last = $last; track crumb.label) {
@for (crumb of breadcrumbs(); let last = $last; track crumb.id) {
@if (crumb.url && !last) {
<app-link [href]="crumb.url">{{ crumb.label }}</app-link>

View File

@ -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 = '';
}

View File

@ -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<AppListDto | null> = (route, state) => {
const appService = inject(AppService);
const id = route.paramMap.get('appId')
return appService.getTenant$(id);
};
export const appNameResolver: ResolveFn<string> = (route, state) => {
const appService = inject(AppService);
const id = route.paramMap.get('appId')
return appService.getTenant$(id).pipe(map(app => app?.name ?? 'Unnamed App'));
};

View File

@ -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<boolean> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => subjectResolver(...resolverParameters));
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
});

View File

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
}

View File

@ -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)
{