OPS-355: Fix roles accesses (#148)

This commit is contained in:
Rinat Arsaev 2023-09-01 13:54:06 +04:00 committed by GitHub
parent adc9a1a167
commit 452cc3b66d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 158 additions and 65 deletions

View File

@ -61,7 +61,9 @@ export function createApi<
private call(name: keyof T, params: Record<PropertyKey, unknown>) {
return this.createExtendedParams().pipe(
switchMap((p) => this.api[name](Object.assign({}, params, ...p))),
switchMap((extendParams) =>
this.api[name](Object.assign({}, ...extendParams, params)),
),
);
}

View File

@ -7,7 +7,9 @@
*ngFor="let roleId of roleIds"
class="dsh-body-2"
fxLayoutAlign="center center"
>{{ (roleIdDict$ | async)?.[roleId] }}</dsh-nested-table-col
><button dsh-button (click)="show(roleId)">
{{ (roleIdDict$ | async)?.[roleId] }}
</button></dsh-nested-table-col
>
<dsh-nested-table-col *ngIf="isAllowAdd">
<button color="accent" dsh-button (click)="add()">{{ t('add') }}</button>

View File

@ -6,18 +6,20 @@ import {
Input,
OnInit,
Output,
OnChanges,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { MemberRole, ResourceScopeId, RoleId } from '@vality/swag-organizations';
import { ComponentChanges } from '@vality/ng-core';
import { MemberRole, ResourceScopeId, RoleId, Organization } from '@vality/swag-organizations';
import { coerceBoolean } from 'coerce-property';
import isNil from 'lodash-es/isNil';
import { BehaviorSubject, combineLatest, EMPTY, Observable, of } from 'rxjs';
import { first, map, switchMap, tap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, EMPTY, Observable, of, ReplaySubject } from 'rxjs';
import { first, map, switchMap, tap, shareReplay } from 'rxjs/operators';
import { OrganizationsDictionaryService } from '@dsh/app/api/organizations';
import { ShopsService } from '@dsh/app/api/payments';
import { DialogConfig, DIALOG_CONFIG } from '@dsh/app/sections/tokens';
import { ShopsDataService } from '@dsh/app/shared';
import { sortRoleIds } from '@dsh/app/shared/components/organization-roles/utils/sort-role-ids';
import { PartialReadonly } from '@dsh/type-utils';
@ -34,7 +36,7 @@ import { SelectRoleDialogData } from './components/select-role-dialog/types/sele
templateUrl: 'change-roles-table.component.html',
styleUrls: ['change-roles-table.component.scss'],
})
export class ChangeRolesTableComponent implements OnInit {
export class ChangeRolesTableComponent implements OnInit, OnChanges {
@Input() set roles(roles: PartialReadonly<MemberRole>[]) {
if (!isNil(roles)) {
this.roles$.next(roles);
@ -44,6 +46,7 @@ export class ChangeRolesTableComponent implements OnInit {
get roles(): PartialReadonly<MemberRole>[] {
return this.roles$.value;
}
@Input() organization: Organization;
/**
* Edit mode:
@ -57,8 +60,14 @@ export class ChangeRolesTableComponent implements OnInit {
@Output() addedRoles = new EventEmitter<PartialReadonly<MemberRole>[]>();
@Output() removedRoles = new EventEmitter<PartialReadonly<MemberRole>[]>();
organization$ = new ReplaySubject<Organization>(1);
roleIds: RoleId[] = [];
shops$ = this.shopsDataService.shops$;
shops$ = this.organization$.pipe(
switchMap((organization) =>
this.shopsService.getShopsForParty({ partyID: organization.party }),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
roleIdDict$ = this.organizationsDictionaryService.roleId$;
get availableRoles(): RoleId[] {
@ -71,20 +80,31 @@ export class ChangeRolesTableComponent implements OnInit {
roles$ = new BehaviorSubject<PartialReadonly<MemberRole>[]>([]);
isAllowRemoves$ = this.roles$.pipe(map((r) => r.length > 1));
isAllowRemoves$ = this.roles$.pipe(
map(
(roles) =>
!this.editMode || roles.some((r) => roles.some((b) => b.roleId !== r.roleId)),
),
);
private get hasAdminRole() {
return !!this.roles.find((r) => r.id === RoleId.Administrator);
}
constructor(
private shopsDataService: ShopsDataService,
private shopsService: ShopsService,
private dialog: MatDialog,
@Inject(DIALOG_CONFIG) private dialogConfig: DialogConfig,
private cdr: ChangeDetectorRef,
private organizationsDictionaryService: OrganizationsDictionaryService,
) {}
ngOnChanges({ organization }: ComponentChanges<ChangeRolesTableComponent>) {
if (organization) {
this.organization$.next(organization.currentValue);
}
}
ngOnInit(): void {
this.roles$.pipe(untilDestroyed(this)).subscribe((roles) => this.selectedRoles.emit(roles));
}
@ -116,6 +136,25 @@ export class ChangeRolesTableComponent implements OnInit {
});
}
show(roleId: RoleId) {
const removeDialogsClass = addDialogsClass(this.dialog.openDialogs, 'dsh-hidden');
this.dialog
.open<SelectRoleDialogComponent, SelectRoleDialogData, SelectRoleDialogResult>(
SelectRoleDialogComponent,
{
...this.dialogConfig.large,
data: { availableRoles: [roleId], isShow: true },
},
)
.afterClosed()
.pipe(untilDestroyed(this))
.subscribe({
complete: () => {
removeDialogsClass();
},
});
}
remove(roleId: RoleId): void {
this.removeRoleIds([roleId]);
this.removeRoles(this.roles.filter((r) => r.roleId === roleId));

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { TranslocoModule } from '@ngneat/transloco';
@ -26,6 +27,7 @@ import { SelectRoleDialogComponent } from './components/select-role-dialog/selec
MatRadioModule,
ReactiveFormsModule,
SelectionModule,
MatButtonModule,
],
declarations: [ChangeRolesTableComponent, SelectRoleDialogComponent],
exports: [ChangeRolesTableComponent],

View File

@ -5,13 +5,13 @@
>
<mat-radio-group [formControl]="roleControl">
<dsh-nested-table [rowsGridTemplateColumns]="rowsGridTemplateColumns">
<dsh-nested-table-row>
<dsh-nested-table-row *ngIf="!data.isShow">
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let role of roles" fxLayoutAlign="center center">
<span class="dsh-body-2 header">{{ (roleIdDict$ | async)?.[role] }}</span>
</dsh-nested-table-col>
</dsh-nested-table-row>
<dsh-nested-table-row>
<dsh-nested-table-row *ngIf="!data.isShow">
<dsh-nested-table-col></dsh-nested-table-col>
<dsh-nested-table-col *ngFor="let role of roles">
<mat-radio-button [value]="role"></mat-radio-button>
@ -33,7 +33,7 @@
</dsh-nested-table-row>
</dsh-nested-table>
</mat-radio-group>
<ng-container dshBaseDialogActions>
<ng-container *ngIf="!data.isShow" dshBaseDialogActions>
<button [disabled]="roleControl.invalid" color="accent" dsh-button (click)="select()">
{{ t('select') }}
</button>

View File

@ -2,4 +2,5 @@ import { RoleId } from '@vality/swag-organizations';
export interface SelectRoleDialogData {
availableRoles: RoleId[];
isShow?: boolean;
}

View File

@ -17,7 +17,10 @@
<div fxLayout="column" fxLayoutGap="24px">
<mat-divider></mat-divider>
<h2 class="dsh-title">{{ t('roles') }}</h2>
<dsh-change-roles-table (selectedRoles)="selectRoles($event)"></dsh-change-roles-table>
<dsh-change-roles-table
[organization]="data.organization"
(selectedRoles)="selectRoles($event)"
></dsh-change-roles-table>
</div>
</div>
<ng-container dshBaseDialogActions>

View File

@ -37,7 +37,7 @@ export class CreateInvitationDialogComponent {
create() {
return this.invitationsService
.createInvitation({
orgId: this.data.orgId,
orgId: this.data.organization.id,
invitationRequest: {
invitee: {
contact: {

View File

@ -1,5 +1,5 @@
import { Organization } from '@vality/swag-organizations';
export type CreateInvitationDialogData = {
orgId: Organization['id'];
organization: Organization;
};

View File

@ -43,7 +43,7 @@ export class InvitationsComponent {
return this.organization$
.pipe(
first(),
switchMap(({ id: orgId }) =>
switchMap((organization) =>
this.dialog
.open<
CreateInvitationDialogComponent,
@ -51,7 +51,7 @@ export class InvitationsComponent {
BaseDialogResponseStatus
>(CreateInvitationDialogComponent, {
...this.dialogConfig.large,
data: { orgId },
data: { organization },
})
.afterClosed(),
),

View File

@ -5,6 +5,7 @@
(cancel)="cancel()"
>
<dsh-change-roles-table
[organization]="data.organization"
[roles]="roles$ | async"
controlled
editMode

View File

@ -21,7 +21,7 @@ export class EditRolesDialogComponent {
roles$ = defer(() => this.updateRoles$).pipe(
switchMap(() =>
this.membersService
.getOrgMember({ orgId: this.data.orgId, userId: this.data.userId })
.getOrgMember({ orgId: this.data.organization.id, userId: this.data.userId })
.pipe(map((r) => r.roles)),
),
untilDestroyed(this),
@ -45,7 +45,7 @@ export class EditRolesDialogComponent {
return forkJoin(
roles.map((memberRole) =>
this.membersService.assignMemberRole({
orgId: this.data.orgId,
orgId: this.data.organization.id,
userId: this.data.userId,
memberRole,
}),
@ -65,7 +65,7 @@ export class EditRolesDialogComponent {
roles.map((role) =>
this.membersService
.removeMemberRole({
orgId: this.data.orgId,
orgId: this.data.organization.id,
userId: this.data.userId,
memberRoleId: role.id,
})

View File

@ -1,4 +1,6 @@
import { Organization } from '@vality/swag-organizations';
export interface EditRolesDialogData {
orgId: string;
organization: Organization;
userId: string;
}

View File

@ -82,7 +82,7 @@ export class MemberComponent implements OnChanges {
.open<EditRolesDialogComponent, EditRolesDialogData>(EditRolesDialogComponent, {
...this.dialogConfig.large,
data: {
orgId: this.organization.id,
organization: this.organization,
userId: this.member.id,
},
})

View File

@ -4,16 +4,18 @@
fxLayoutGap="32px"
>
<nav [tabPanel]="tabPanel" mat-tab-nav-bar>
<a
#rla="routerLinkActive"
*ngFor="let link of links"
[active]="rla.isActive"
[routerLink]="link.path"
mat-tab-link
routerLinkActive
>
<span>{{ link.label$ | async }}</span>
</a>
<ng-container *ngFor="let link of links">
<a
#rla="routerLinkActive"
*ngIf="link.roles | isAccessAllowed: 'some'"
[active]="rla.isActive"
[routerLink]="link.path"
mat-tab-link
routerLinkActive
>
<span>{{ link.label$ | async }}</span>
</a>
</ng-container>
</nav>
<mat-tab-nav-panel #tabPanel>
<router-outlet></router-outlet>

View File

@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { RoleAccessName } from '@dsh/app/auth';
@Component({
templateUrl: 'integrations.component.html',
})
@ -13,6 +15,7 @@ export class IntegrationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.PaymentLinks],
},
{
path: 'api-keys',
@ -21,6 +24,7 @@ export class IntegrationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.ApiKeys],
},
{
path: 'webhooks',
@ -29,6 +33,7 @@ export class IntegrationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.Webhooks],
},
];

View File

@ -4,6 +4,7 @@ import { FlexModule } from '@angular/flex-layout';
import { MatTabsModule } from '@angular/material/tabs';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { LayoutModule } from '@dsh/components/layout';
import { ScrollUpModule } from '@dsh/components/navigation';
@ -19,6 +20,7 @@ import { IntegrationsComponent } from './integrations.component';
TranslocoModule,
ScrollUpModule,
MatTabsModule,
AuthModule,
],
declarations: [IntegrationsComponent],
})

View File

@ -5,16 +5,18 @@
fxLayoutGap="32px"
>
<nav mat-tab-nav-bar>
<a
#rla="routerLinkActive"
*ngFor="let link of links"
[active]="rla.isActive"
[routerLink]="link.path"
mat-tab-link
routerLinkActive
>
<span>{{ link.label$ | async }}</span>
</a>
<ng-container *ngFor="let link of links">
<a
#rla="routerLinkActive"
*ngIf="link.roles | isAccessAllowed: 'some'"
[active]="rla.isActive"
[routerLink]="link.path"
mat-tab-link
routerLinkActive
>
<span>{{ link.label$ | async }}</span>
</a>
</ng-container>
</nav>
<div>
<router-outlet></router-outlet>

View File

@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { RoleAccessName } from '@dsh/app/auth';
@Component({
templateUrl: 'operations.component.html',
})
@ -13,6 +15,7 @@ export class OperationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.ViewPayments],
},
{
path: 'invoices',
@ -21,6 +24,7 @@ export class OperationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.ViewInvoices],
},
{
path: 'refunds',
@ -29,6 +33,7 @@ export class OperationsComponent {
null,
'payment-section',
),
roles: [RoleAccessName.ViewRefunds],
},
];

View File

@ -4,6 +4,7 @@ import { FlexLayoutModule } from '@angular/flex-layout';
import { MatTabsModule } from '@angular/material/tabs';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { LayoutModule } from '@dsh/components/layout';
import { ScrollUpModule } from '@dsh/components/navigation';
@ -19,6 +20,7 @@ import { OperationsComponent } from './operations.component';
TranslocoModule,
ScrollUpModule,
MatTabsModule,
AuthModule,
],
declarations: [OperationsComponent],
})

View File

@ -31,11 +31,18 @@ const PAYMENT_SECTION_ROUTES: Routes = [
},
[RoleAccessName.ViewAnalytics],
),
{
path: 'operations',
loadChildren: () =>
import('./operations/operations.module').then((m) => m.OperationsModule),
},
createPrivateRoute(
{
path: 'operations',
loadChildren: () =>
import('./operations/operations.module').then((m) => m.OperationsModule),
},
[
RoleAccessName.ViewPayments,
RoleAccessName.ViewInvoices,
RoleAccessName.ViewRefunds,
],
),
createPrivateRoute(
{
path: 'reports',
@ -51,11 +58,16 @@ const PAYMENT_SECTION_ROUTES: Routes = [
// },
// [RoleAccessName.ViewPayouts]
// ),
{
path: 'integrations',
loadChildren: () =>
import('./integrations/integrations.module').then((m) => m.IntegrationsModule),
},
createPrivateRoute(
{
path: 'integrations',
loadChildren: () =>
import('./integrations/integrations.module').then(
(m) => m.IntegrationsModule,
),
},
[RoleAccessName.PaymentLinks, RoleAccessName.ApiKeys, RoleAccessName.Webhooks],
),
],
},
];

View File

@ -3,7 +3,13 @@
fxLayout="column"
fxLayoutGap="32px"
>
<div fxLayout="column" fxLayout.gt-sm="row" fxLayoutAlign="end" fxLayoutGap="16px">
<div
*ngIf="'Claims' | isAccessAllowed"
fxLayout="column"
fxLayout.gt-sm="row"
fxLayoutAlign="end"
fxLayoutGap="16px"
>
<button color="accent" dsh-button (click)="createShop()">{{ t('createShop') }}</button>
</div>
<dsh-shops-list

View File

@ -4,6 +4,7 @@ import { FlexLayoutModule } from '@angular/flex-layout';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { ShopCreationModule } from '@dsh/app/shared/components/shop-creation';
import { ButtonModule } from '@dsh/components/buttons';
@ -26,6 +27,7 @@ import { ShopsComponent } from './shops.component';
ShopCreationModule,
ButtonModule,
TranslocoModule,
AuthModule,
],
declarations: [ShopsComponent],
exports: [ShopsComponent],

View File

@ -59,7 +59,7 @@ export const toNavbarItemConfig = ({
routerLink: NavbarRouterLink.Reports,
icon: BootstrapIconName.FileText,
label: reports,
roles: [],
roles: [RoleAccessName.Reports],
},
{
routerLink: NavbarRouterLink.Integrations,

View File

@ -5,7 +5,7 @@ import { map, pluck, shareReplay, switchMap } from 'rxjs/operators';
import { MembersService } from '@dsh/app/api/organizations';
import { SHARE_REPLAY_CONF } from '@dsh/app/custom-operators';
import { ContextOrganizationService } from '@dsh/app/shared';
import { KeycloakTokenInfoService } from '@dsh/app/shared';
import { Initializable } from '@dsh/app/shared/types';
@Injectable()
@ -16,16 +16,19 @@ export class OrganizationManagementService implements Initializable {
shareReplay(SHARE_REPLAY_CONF),
);
isOrganizationOwner$: Observable<boolean> = defer(() =>
combineLatest([
this.organization$,
this.contextOrganizationService.organization$.pipe(pluck('party')),
]),
combineLatest([this.organization$, this.keycloakTokenInfoService.userID$]),
).pipe(
map(([{ owner }, id]) => owner === id),
shareReplay(SHARE_REPLAY_CONF),
);
isOrganizationAdmin$: Observable<boolean> = this.contextOrganizationService.member$.pipe(
map((member) => member.roles.findIndex((r) => r.roleId === RoleId.Administrator) !== -1),
isOrganizationAdmin$: Observable<boolean> = combineLatest([
this.members$,
this.keycloakTokenInfoService.userID$,
]).pipe(
map(([members, userId]) => members.find((m) => m.id === userId)),
map(
(member) => member?.roles?.findIndex?.((r) => r.roleId === RoleId.Administrator) !== -1,
),
shareReplay(SHARE_REPLAY_CONF),
);
hasAdminAccess$: Observable<boolean> = defer(() =>
@ -39,7 +42,7 @@ export class OrganizationManagementService implements Initializable {
constructor(
private membersService: MembersService,
private contextOrganizationService: ContextOrganizationService,
private keycloakTokenInfoService: KeycloakTokenInfoService,
) {}
init(organization: Organization) {

View File

@ -115,7 +115,7 @@
"manageWebhooks": "Manage webhooks",
"payments": "Payments",
"viewAnalytics": "View analytics",
"viewApiKey": "View the API key",
"viewApiKey": "View API keys",
"viewInvoices": "View invoices",
"viewPayments": "View payments",
"viewPayouts": "View payouts",

View File

@ -115,7 +115,7 @@
"manageWebhooks": "Управление Webhooks",
"payments": "Платежи",
"viewAnalytics": "Просмотр аналитики",
"viewApiKey": "Просмотр API ключа",
"viewApiKey": "Просмотр API ключей",
"viewInvoices": "Просмотр инвойсов",
"viewPayments": "Просмотр платежей",
"viewPayouts": "Просмотр возмещений",