This commit is contained in:
Christian Werner 2025-10-31 19:45:15 +01:00
parent 7abcdcf8f1
commit 548d874221
20 changed files with 417 additions and 57 deletions

View File

@ -92,56 +92,32 @@ export const routes: Routes = [
tenant: tenantResolver, tenant: tenantResolver,
title: tenantNameResolver, title: tenantNameResolver,
}, },
},
{
title: 'Subject TODO',
path: 'subject/:subjectId',
component: HomeComponent
},
{
path: ':id',
resolve: {
title: tenantNameResolver,
},
children: [ children: [
{
title: 'Subject TODO',
path: 'subject/:subjectId',
component: HomeComponent
},
{ {
title: 'Apps', title: 'Apps',
path: 'apps', path: 'app',
component: HomeComponent,
children: [ children: [
{ {
title: 'App TODO', title: 'App TODO',
path: ':id', path: ':appId',
component: HomeComponent, component: HomeComponent
children: [
{
title: 'Groups',
path: 'groups',
component: HomeComponent,
},
{
title: 'Roles',
path: 'roles',
component: HomeComponent,
},
{
title: 'Authorities',
path: 'authorities',
component: HomeComponent,
},
]
} }
] ],
}, data: {
{ breadcrumbUrl: '?tab=apps'
title: 'Groups', }
path: 'groups', }
component: HomeComponent,
},
{
title: 'Roles',
path: 'roles',
component: HomeComponent,
},
{
title: 'Authorities',
path: 'authorities',
component: HomeComponent,
},
] ]
}, },
] ]

View File

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

View File

@ -0,0 +1,3 @@
import {AuthorityDto} from './authority-dto';
export interface GroupDto extends AuthorityDto {}

View File

@ -0,0 +1,3 @@
import {AuthorityDto} from './authority-dto';
export interface RoleDto extends AuthorityDto {}

View File

@ -3,6 +3,9 @@ import {lastValueFrom, Observable, of} from "rxjs";
import {PalantirService} from '../palantir.service'; import {PalantirService} from '../palantir.service';
import {AppListDto} from '../dtos/app-list-dto'; import {AppListDto} from '../dtos/app-list-dto';
import {TenantGridViewDto} from '../dtos/tenant-grid-view-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({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -39,4 +42,28 @@ export class TenantService extends PalantirService {
getTenantAppsAsync(id: string, onError?: (error: any) => void): Promise<AppListDto[]> { getTenantAppsAsync(id: string, onError?: (error: any) => void): Promise<AppListDto[]> {
return lastValueFrom(this.getTenantApps$(id, onError)); return lastValueFrom(this.getTenantApps$(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

@ -1,5 +1,5 @@
import {Component, inject, input} from '@angular/core'; import {Component, inject, input} from '@angular/core';
import {Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
@Component({ @Component({
selector: 'app-link', selector: 'app-link',
@ -13,11 +13,39 @@ export class LinkComponent {
public external = input<boolean>(false); public external = input<boolean>(false);
private router = inject(Router); private router = inject(Router);
private route = inject(ActivatedRoute);
click($event: PointerEvent) { click($event: PointerEvent) {
if (!this.external()) { if (!this.external()) {
$event.preventDefault(); $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();
} }
} }

View File

@ -0,0 +1,13 @@
@if (loading()) {
Loading...
} @else {
@if (items().length === 0) {
<span>List is empty :(</span>
}
@else {
@for (item of items(); track item.id) {
<ng-container *ngTemplateOutlet="itemTemplate(); context: { $implicit: item, metadata: metadata() }"></ng-container>
}
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListComponent } from './list.component';
describe('ListComponent', () => {
let component: ListComponent;
let fixture: ComponentFixture<ListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<Identifiable[]>();
public itemTemplate = input.required<TemplateRef<any>>();
public loading = input<boolean>(false);
public metadata = input<any>();
}

View File

@ -21,7 +21,7 @@ export class LoginComponent {
protected loginFormGroup = new FormGroup({ protected loginFormGroup = new FormGroup({
usernameOrEmail: new FormControl('housemaster'), usernameOrEmail: new FormControl('housemaster'),
password: new FormControl('kR0pNCspBKx8lOzAIch5'), password: new FormControl('W33PWO6IIRpkc6VSBIY0'),
keepSignedIn: new FormControl(false), keepSignedIn: new FormControl(false),
}); });

View File

@ -1,6 +1,6 @@
import {Component, computed, OnInit, signal} from '@angular/core'; import {Component, computed, OnInit, signal} from '@angular/core';
import {NavProfileComponent} from './nav-profile/nav-profile.component'; 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 {IconComponent} from '../icon/icon.component';
import {DrawerComponent} from './drawer/drawer.component'; import {DrawerComponent} from './drawer/drawer.component';
import {filter} from 'rxjs'; import {filter} from 'rxjs';
@ -13,7 +13,6 @@ import {LinkComponent} from '../link/link.component';
NavProfileComponent, NavProfileComponent,
IconComponent, IconComponent,
DrawerComponent, DrawerComponent,
RouterLink,
NgClass, NgClass,
LinkComponent LinkComponent
], ],
@ -86,14 +85,27 @@ export class ShellComponent implements OnInit {
const child = children[0]; const child = children[0];
const routeURL = child.snapshot.url.map(segment => segment.path).join('/'); 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}`; url += !url.endsWith('/') ? `/${routeURL}` : `${routeURL}`;
} }
const label = const label =
child.snapshot.data['title'] || child.snapshot.routeConfig?.title
child.snapshot.routeConfig?.title || || child.snapshot.data['title']
''; || '';
if (label) { if (label) {
breadcrumbs.push({ label, url }); breadcrumbs.push({ label, url });

View File

@ -9,15 +9,30 @@
</ng-template> </ng-template>
<ng-template tabId="groups" let-tab> <ng-template tabId="groups" let-tab>
{{tab.name}} - content <app-list [itemTemplate]="authorityItem" [items]="groups()" [loading]="groupsLoading()" metadata="group"></app-list>
</ng-template> </ng-template>
<ng-template tabId="roles" let-tab> <ng-template tabId="roles" let-tab>
{{tab.name}} - content <app-list [itemTemplate]="authorityItem" [items]="roles()" [loading]="rolesLoading()" metadata="role"></app-list>
</ng-template> </ng-template>
<ng-template tabId="authorities" let-tab> <ng-template tabId="authorities" let-tab>
{{tab.name}} - content <app-list [itemTemplate]="authorityItem" [items]="authorities()" [loading]="authoritiesLoading()" metadata="authority"></app-list>
</ng-template> </ng-template>
</app-tab-group> </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

@ -1,9 +1,15 @@
import {Component, inject, signal} from '@angular/core'; import {Component, inject, signal} from '@angular/core';
import {TabGroup, TabGroupComponent} from '../../tab-group/tab-group.component'; import {TabGroup, TabGroupComponent} from '../../tab-group/tab-group.component';
import {ActiveTabDirective} from '../../tab-group/active-tab.directive'; 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 {SubjectListComponent} from '../../subject/subject-list/subject-list.component';
import {AppListComponent} from '../../app/app-list/app-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({ @Component({
selector: 'app-tenant-detail', selector: 'app-tenant-detail',
@ -11,7 +17,10 @@ import {AppListComponent} from '../../app/app-list/app-list.component';
TabGroupComponent, TabGroupComponent,
ActiveTabDirective, ActiveTabDirective,
SubjectListComponent, SubjectListComponent,
AppListComponent AppListComponent,
ListComponent,
PanelComponent,
RouterLink
], ],
templateUrl: './tenant-detail.component.html', templateUrl: './tenant-detail.component.html',
styleUrl: './tenant-detail.component.scss', styleUrl: './tenant-detail.component.scss',
@ -44,8 +53,16 @@ export class TenantDetailComponent {
protected activeTabId = signal<string | null>(null) protected activeTabId = signal<string | null>(null)
protected tenantId = signal<string | null>(null) protected tenantId = 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 route = inject(ActivatedRoute);
private router = inject(Router) private router = inject(Router)
private tenantService = inject(TenantService);
constructor() { constructor() {
const routeSnapshot = this.route.snapshot; const routeSnapshot = this.route.snapshot;
@ -65,5 +82,35 @@ export class TenantDetailComponent {
skipLocationChange: false, skipLocationChange: false,
replaceUrl: true 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;
}
} }
} }

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Statistic">
<option name="fileTypesIncluded" value="html;ts;cs;scss;css;" />
</component>
</project>

View File

@ -5,8 +5,11 @@ using Microsoft.EntityFrameworkCore;
using Suspectus.Gandalf.Palantir.Abstractions; using Suspectus.Gandalf.Palantir.Abstractions;
using Suspectus.Gandalf.Palantir.Data.Database; using Suspectus.Gandalf.Palantir.Data.Database;
using Suspectus.Gandalf.Palantir.Data.Dto.App; 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.Dto.Tenant;
using Suspectus.Gandalf.Palantir.Data.Entities.Base; using Suspectus.Gandalf.Palantir.Data.Entities.Base;
using Suspectus.Gandalf.Palantir.Data.Entities.Security.Permission.Data;
namespace Suspectus.Gandalf.Palantir.Api.Controllers; namespace Suspectus.Gandalf.Palantir.Api.Controllers;
@ -121,4 +124,130 @@ public class TenantController : ControllerBase
return Ok(dtos); return Ok(dtos);
} }
[HttpGet("{tenantIdHash}/groups")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
}
} }

View File

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

View File

@ -0,0 +1,5 @@
using Suspectus.Gandalf.Palantir.Data.Dto.Authority;
namespace Suspectus.Gandalf.Palantir.Data.Dto.Group;
public class GroupDto : AuthorityDto;

View File

@ -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;