local changes

This commit is contained in:
conblem
2025-07-08 15:15:49 +02:00
parent 5cb108efba
commit 17ea1b3868
12 changed files with 182 additions and 77 deletions

View File

@@ -1,7 +1,7 @@
<ng-container *ngIf="['iam.read$', 'iam.write$'] | hasRole as iamuser$">
<div class="nav-col" [ngClass]="{ 'is-admin': (iamuser$ | async) }">
<ng-container
*ngIf="breadcrumbService.breadcrumbsExtended$ && (breadcrumbService.breadcrumbsExtended$ | async) as breadc"
*ngIf="breadcrumbService.breadcrumbsExtended$ | async as breadc"
>
<ng-container
*ngIf="
@@ -96,12 +96,7 @@
[routerLinkActive]="['active']"
[routerLinkActiveOptions]="{ exact: false }"
[routerLink]="['/org-settings']"
*ngIf="
(['policy.read'] | hasRole | async) &&
((['iam.read$', 'iam.write$'] | hasRole | async) === false ||
(((authService.cachedOrgs | async)?.length ?? 1) > 1 &&
(['iam.read$', 'iam.write$'] | hasRole | async)))
"
*ngIf="['policy.read'] | hasRole | async"
>
<span class="label">{{ 'MENU.SETTINGS' | translate }}</span>
</a>

View File

@@ -1,6 +1,6 @@
<button class="header-button" cnslInput>
<span class="header-text">
<ng-content></ng-content>
<div class="cnsl-action-button">
<ng-icon size="1.2rem" name="heroChevronUpDown"></ng-icon>
</div>
</span>
<button class="header-button" matRipple [matRippleUnbounded]="false">
<ng-icon size="1.2rem" name="heroChevronUpDown"></ng-icon>
</button>

View File

@@ -1,5 +1,4 @@
.header-button {
width: unset;
:host {
display: flex;
flex-direction: row;
align-items: center;
@@ -8,4 +7,23 @@
padding-right: 0;
height: 32px;
max-height: 32px;
cursor: pointer;
}
.header-text {
font-size: 1.1rem;
}
@mixin header-button-theme($theme) {
$foreground: map-get($theme, foreground);
.header-button {
border: 0;
background: transparent;
border-radius: 4px;
padding: 0;
height: 100%;
cursor: pointer;
color: map-get($foreground, text);
}
}

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { heroChevronUpDown } from '@ng-icons/heroicons/outline';
import { MatRippleModule } from '@angular/material/core';
@Component({
selector: 'cnsl-header-button',
@@ -8,7 +9,7 @@ import { heroChevronUpDown } from '@ng-icons/heroicons/outline';
styleUrls: ['./header-button.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIconComponent],
imports: [NgIconComponent, MatRippleModule],
providers: [provideIcons({ heroChevronUpDown })],
})
export class HeaderButtonComponent {}

View File

@@ -1,12 +1,12 @@
<div class="new-header-wrapper">
<span routerLink="/" class="new-header-title">CONSOLE</span>
<span *ngIf="!isHandset()" routerLink="/" class="new-header-title">CONSOLE</span>
<ng-container *ngIf="myInstanceQuery.data()?.instance as instance">
<ng-container *ngTemplateOutlet="slash"></ng-container>
<cnsl-header-button
cdkOverlayOrigin
#instanceTrigger="cdkOverlayOrigin"
(click)="isInstanceDropdownOpen.set(!isInstanceDropdownOpen())"
>Instance</cnsl-header-button
>{{ instance.name }}</cnsl-header-button
>
<cnsl-header-dropdown
[trigger]="instanceTrigger"
@@ -40,6 +40,14 @@
<cnsl-organization-selector (orgChanged)="isOrgDropdownOpen.set(false)"></cnsl-organization-selector>
</cnsl-header-dropdown>
</ng-container>
<ng-container *ngIf="!isHandset()">
<ng-container *ngFor="let bread of breadcrumbs(); index as i; let last = last">
<ng-container *ngTemplateOutlet="slash"></ng-container>
<a matRipple [matRippleUnbounded]="false" [routerLink]="bread.routerLink">
{{ bread.name }}
</a>
</ng-container>
</ng-container>
</div>
<ng-template #slash>

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, effect, Signal, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, Signal, signal } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { NewOrganizationService } from '../../services/new-organization.service';
import { ToastService } from '../../services/toast.service';
import { AsyncPipe, NgIf, NgTemplateOutlet } from '@angular/common';
import { AsyncPipe, NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { OrganizationSelectorComponent } from './organization-selector/organization-selector.component';
import { CdkOverlayOrigin } from '@angular/cdk/overlay';
@@ -18,6 +18,8 @@ import { BreakpointObserver } from '@angular/cdk/layout';
import { NewAdminService } from '../../services/new-admin.service';
import { NewAuthService } from '../../services/new-auth.service';
import { RouterLink } from '@angular/router';
import { Breadcrumb, BreadcrumbService, BreadcrumbType } from '../../services/breadcrumb.service';
import { MatRippleModule } from '@angular/material/core';
@Component({
selector: 'cnsl-new-header',
@@ -39,6 +41,8 @@ import { RouterLink } from '@angular/router';
AsyncPipe,
HasRolePipeModule,
RouterLink,
NgForOf,
MatRippleModule,
],
})
export class NewHeaderComponent {
@@ -53,6 +57,7 @@ export class NewHeaderComponent {
protected readonly instanceSelectorSecondStep = signal(false);
protected readonly activeOrganizationQuery = this.newOrganizationService.activeOrganizationQuery();
protected readonly isHandset: Signal<boolean>;
protected readonly breadcrumbs: Signal<Breadcrumb[]>;
constructor(
private readonly newOrganizationService: NewOrganizationService,
@@ -60,8 +65,10 @@ export class NewHeaderComponent {
private readonly breakpointObserver: BreakpointObserver,
private readonly adminService: NewAdminService,
private readonly newAuthService: NewAuthService,
private readonly breadcrumbService: BreadcrumbService,
) {
this.isHandset = this.getIsHandset();
this.breadcrumbs = this.getBreadcrumbs();
effect(() => {
if (this.listMyZitadelPermissionsQuery.isError()) {
@@ -87,4 +94,13 @@ export class NewHeaderComponent {
const isHandset$ = this.breakpointObserver.observe(mediaQuery).pipe(map(({ matches }) => matches));
return toSignal(isHandset$, { initialValue: this.breakpointObserver.isMatched(mediaQuery) });
}
private getBreadcrumbs() {
const breadcrumbs = toSignal(this.breadcrumbService.breadcrumbs$, { initialValue: [] });
return computed(() =>
breadcrumbs().filter(
(breadcrumb) => breadcrumb.type === BreadcrumbType.PROJECT || breadcrumb.type === BreadcrumbType.APP,
),
);
}
}

View File

@@ -24,21 +24,22 @@
{{ org.name }}
<ng-icon name="heroCheck"></ng-icon>
</a>
<ng-container *ngFor="let page of organizationsQuery.data()?.pages; last as lastPage">
<ng-container *ngFor="let org of page.result; trackBy: trackOrg">
<ng-container *ngIf="organizationsQuery.data() as data">
<ng-container *ngFor="let org of data.orgs; trackBy: trackOrgResponse">
<a *ngIf="org.id !== activeOrg.data()?.id" class="dropdown-button" mat-button (click)="changeOrg(org.id)">
{{ org.name }}
</a>
</ng-container>
<ng-container *ngIf="lastPage && page.details?.totalResult as totalResult">
<ng-container *ngIf="data?.totalResult as totalResult">
<button
#moreButton
class="dropdown-button"
mat-stroked-button
*ngIf="totalResult > QUERY_LIMIT"
(click)="organizationsQuery.fetchNextPage()"
[disabled]="!organizationsQuery.hasNextPage() || organizationsQuery.isFetchingNextPage()"
>
...{{ totalResult - loadedOrgsCount() }} {{ 'PAGINATOR.MORE' | translate }}
...{{ totalResult - data.orgs.length }} {{ 'PAGINATOR.MORE' | translate }}
</button>
</ng-container>
</ng-container>

View File

@@ -1,4 +1,17 @@
import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Input, Output, Signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
EventEmitter,
Input,
Output,
signal,
Signal,
ViewChild,
} from '@angular/core';
import { injectInfiniteQuery, injectMutation, keepPreviousData } from '@tanstack/angular-query-experimental';
import { NewOrganizationService } from 'src/app/services/new-organization.service';
import { NgForOf, NgIf } from '@angular/common';
@@ -20,13 +33,14 @@ import { Router } from '@angular/router';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { heroCheck, heroMagnifyingGlass } from '@ng-icons/heroicons/outline';
import { heroArrowLeftCircleSolid } from '@ng-icons/heroicons/solid';
import { UserService } from '../../../services/user.service';
type NameQuery = Extract<
NonNullable<MessageInitShape<typeof ListOrganizationsRequestSchema>['queries']>[number]['query'],
{ case: 'nameQuery' }
>;
const QUERY_LIMIT = 5;
const QUERY_LIMIT = 20;
@Component({
selector: 'cnsl-organization-selector',
@@ -58,6 +72,13 @@ export class OrganizationSelectorComponent {
@Output()
public orgChanged = new EventEmitter<Organization>();
@ViewChild('moreButton', { static: false, read: ElementRef })
public set moreButton(button: ElementRef<HTMLButtonElement>) {
this.moreButtonSignal.set(button);
}
private moreButtonSignal = signal<ElementRef<HTMLButtonElement> | undefined>(undefined);
protected setOrgId = injectMutation(() => ({
mutationFn: (orgId: string) => this.newOrganizationService.setOrgId(orgId),
}));
@@ -65,7 +86,6 @@ export class OrganizationSelectorComponent {
protected readonly form: ReturnType<typeof this.buildForm>;
private readonly nameQuery: Signal<NameQuery | undefined>;
protected readonly organizationsQuery: ReturnType<typeof this.getOrganizationsQuery>;
protected loadedOrgsCount: Signal<bigint>;
protected activeOrg = this.newOrganizationService.activeOrganizationQuery();
protected activeOrgIfSearchMatches: Signal<Organization | undefined>;
@@ -73,12 +93,13 @@ export class OrganizationSelectorComponent {
private readonly newOrganizationService: NewOrganizationService,
private readonly formBuilder: FormBuilder,
private readonly router: Router,
private readonly destroyRef: DestroyRef,
private readonly userService: UserService,
toast: ToastService,
) {
this.form = this.buildForm();
this.nameQuery = this.getNameQuery(this.form);
this.organizationsQuery = this.getOrganizationsQuery(this.nameQuery);
this.loadedOrgsCount = this.getLoadedOrgsCount(this.organizationsQuery);
this.activeOrgIfSearchMatches = this.getActiveOrgIfSearchMatches(this.nameQuery);
effect(() => {
@@ -99,12 +120,38 @@ export class OrganizationSelectorComponent {
effect(() => {
const orgId = newOrganizationService.orgId();
const orgs = this.organizationsQuery.data()?.pages[0]?.result;
const orgs = this.organizationsQuery.data()?.orgs;
if (orgId || !orgs || orgs.length === 0) {
return;
}
const _ = newOrganizationService.setOrgId(orgs[0].id);
});
this.infiniteScrollLoading();
}
private infiniteScrollLoading() {
const intersection = new IntersectionObserver(async (entries) => {
if (!entries[0]?.isIntersecting) {
return;
}
await this.organizationsQuery.fetchNextPage();
});
this.destroyRef.onDestroy(() => {
intersection.disconnect();
});
effect((onCleanup) => {
const moreButton = this.moreButtonSignal()?.nativeElement;
if (!moreButton) {
return;
}
intersection.observe(moreButton);
onCleanup(() => {
intersection.unobserve(moreButton);
});
});
}
private buildForm() {
@@ -136,9 +183,12 @@ export class OrganizationSelectorComponent {
private getOrganizationsQuery(nameQuery: Signal<NameQuery | undefined>) {
return injectInfiniteQuery(() => {
const query = nameQuery();
const exp = this.userService.exp();
const isExpired = exp ? exp <= new Date() : true;
return {
queryKey: ['organization', 'listOrganizationsInfinite', query],
queryFn: ({ pageParam, signal }) => this.newOrganizationService.listOrganizations(pageParam, signal),
enabled: !isExpired,
initialPageParam: {
query: {
limit: QUERY_LIMIT,
@@ -157,14 +207,14 @@ export class OrganizationSelectorComponent {
},
}
: undefined,
select: (data) => ({
orgs: data.pages.flatMap((page) => page.result),
totalResult: Number(data.pages[data.pages.length - 1]?.details?.totalResult ?? 0),
}),
};
});
}
private getLoadedOrgsCount(organizationsQuery: ReturnType<typeof this.getOrganizationsQuery>) {
return computed(() => this.countLoadedOrgs(organizationsQuery.data()?.pages));
}
private countLoadedOrgs(pages?: ListOrganizationsResponse[]) {
if (!pages) {
return BigInt(0);
@@ -189,7 +239,7 @@ export class OrganizationSelectorComponent {
await this.router.navigate(['/org']);
}
protected trackOrg(_: number, { id }: Organization): string {
protected trackOrgResponse(_: number, { id }: Organization): string {
return id;
}

View File

@@ -112,20 +112,23 @@ export class AuthUserDetailComponent implements OnInit {
filter(Boolean),
);
effect(() => {
const user = this.user.data();
if (!user || user.type.case !== 'human') {
return;
}
effect(
() => {
const user = this.user.data();
if (!user || user.type.case !== 'human') {
return;
}
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: user.type.value.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
});
this.breadcrumbService.setBreadcrumb([
new Breadcrumb({
type: BreadcrumbType.AUTHUSER,
name: user.type.value.profile?.displayName,
routerLink: ['/users', 'me'],
}),
]);
},
{ allowSignalWrites: true },
);
effect(() => {
const error = this.user.error();

View File

@@ -121,7 +121,6 @@ export class GrpcAuthService {
PrivacyPolicy.AsObject | undefined
>(undefined);
public cachedOrgs: BehaviorSubject<Org.AsObject[]> = new BehaviorSubject<Org.AsObject[]>([]);
private cachedLabelPolicies: { [orgId: string]: LabelPolicy.AsObject } = {};
private cachedPrivacyPolicies: { [orgId: string]: PrivacyPolicy.AsObject } = {};
@@ -272,11 +271,6 @@ export class GrpcAuthService {
return this.grpcService.auth.getMyUser(new GetMyUserRequest(), null).then((resp) => resp.toObject());
}
public async revalidateOrgs() {
const orgs = (await this.listMyProjectOrgs(ORG_LIMIT, 0)).resultList;
this.cachedOrgs.next(orgs);
}
public listMyProjectOrgs(
limit?: number,
offset?: number,

View File

@@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core';
import { computed, Injectable, Signal, signal } from '@angular/core';
import { GrpcService } from './grpc.service';
import {
AddHumanUserRequestSchema,
@@ -48,8 +48,7 @@ import {
import type { MessageInitShape } from '@bufbuild/protobuf';
import { create } from '@bufbuild/protobuf';
import { OAuthService } from 'angular-oauth2-oidc';
import { EMPTY, of, switchMap } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
import { filter, map } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectQuery, queryOptions, skipToken } from '@tanstack/angular-query-experimental';
@@ -57,7 +56,9 @@ import { injectQuery, queryOptions, skipToken } from '@tanstack/angular-query-ex
providedIn: 'root',
})
export class UserService {
private userId = this.getUserId();
private readonly payload: Signal<unknown | undefined>;
private readonly userId: Signal<string | undefined>;
public readonly exp: Signal<Date | undefined>;
public userQuery() {
return injectQuery(() => this.userQueryOptions());
@@ -74,35 +75,51 @@ export class UserService {
constructor(
private readonly grpcService: GrpcService,
private readonly oauthService: OAuthService,
) {}
) {
this.payload = this.getPayload();
this.userId = this.getUserId(this.payload);
this.exp = this.getExp(this.payload);
}
private getUserId() {
const userId$ = this.oauthService.events.pipe(
private getPayload() {
const idToken$ = this.oauthService.events.pipe(
filter((event) => event.type === 'token_received'),
// can actually return null
// https://github.com/manfredsteyer/angular-oauth2-oidc/blob/c724ad73eadbb28338b084e3afa5ed49a0ea058c/projects/lib/src/oauth-service.ts#L2365
map(() => this.oauthService.getIdToken() as string | null),
startWith(this.oauthService.getIdToken() as string | null),
filter(Boolean),
switchMap((token) => {
// we do this in a try catch so the observable will retry this logic if it fails
try {
// split jwt and get base64 encoded payload
const unparsedPayload = atob(token.split('.')[1]);
// parse payload
const payload: unknown = JSON.parse(unparsedPayload);
// check if sub is in payload and is a string
if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') {
return of(payload.sub);
}
return EMPTY;
} catch {
return EMPTY;
}
}),
);
const idToken = toSignal(idToken$, { initialValue: this.oauthService.getIdToken() as string | null });
return toSignal(userId$, { initialValue: undefined });
return computed(() => {
try {
// split jwt and get base64 encoded payload
const unparsedPayload = atob((idToken() ?? '').split('.')[1]);
// parse payload
return JSON.parse(unparsedPayload) as unknown;
} catch {
return undefined;
}
});
}
private getUserId(payloadSignal: Signal<unknown | undefined>) {
return computed(() => {
const payload = payloadSignal();
if (payload && typeof payload === 'object' && 'sub' in payload && typeof payload.sub === 'string') {
return payload.sub;
}
return undefined;
});
}
private getExp(payloadSignal: Signal<unknown | undefined>) {
return computed(() => {
const payload = payloadSignal();
if (payload && typeof payload === 'object' && 'exp' in payload && typeof payload.exp === 'number') {
return new Date(payload.exp * 1000);
}
return undefined;
});
}
public addHumanUser(req: MessageInitShape<typeof AddHumanUserRequestSchema>): Promise<AddHumanUserResponse> {

View File

@@ -81,6 +81,7 @@
@import 'src/app/modules/new-header/organization-selector/organization-selector.component.scss';
@import 'src/app/modules/new-header/instance-selector/instance-selector.component.scss';
@import 'src/app/modules/new-header/header-dropdown/header-dropdown.component.scss';
@import 'src/app/modules/new-header/header-button/header-button.component.scss';
@mixin component-themes($theme) {
@include cnsl-color-theme($theme);
@@ -165,4 +166,5 @@
@include organization-selector-theme($theme);
@include instance-selector-theme($theme);
@include header-dropdown-theme($theme);
@include header-button-theme($theme);
}