OPS-180,182: Use org party for req-s (#93)

This commit is contained in:
Rinat Arsaev 2022-11-30 08:57:13 +06:00 committed by GitHub
parent 3a7bd802a3
commit b8ae56c301
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 666 additions and 501 deletions

2
.gitignore vendored
View File

@ -49,4 +49,4 @@ Thumbs.db
.angular
# Configs
src/*Config.json
src/*Config*.json

View File

@ -23,7 +23,6 @@
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
<option name="IMPORT_SORT_MEMBERS" value="false" />
<option name="USE_PATH_MAPPING" value="DIFFERENT_PATHS" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />

22
.vscode/launch.json vendored
View File

@ -1,22 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Chrome",
"url": "http://localhost:8000",
"webRoot": "${workspaceFolder}"
},
{
"type": "vscode-edge-devtools.debug",
"request": "launch",
"name": "Edge",
"url": "http://localhost:8000",
"webRoot": "${workspaceFolder}"
}
]
}

14
.vscode/settings.json vendored
View File

@ -47,17 +47,5 @@
],
"cSpell.language": "en,ru",
"prettier.prettierPath": "node_modules/prettier",
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/node_modules": true,
"**/.idea": true,
"coverage": true,
"**/.eslintcache": true,
"build_utils": true,
"**/.vscode": true
}
"tasksStatusbar.taskLabelFilter": "Start",
}

17
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "Start dev"
},
{
"type": "npm",
"script": "stage",
"problemMatcher": [],
"label": "Start stage"
}
]
}

View File

@ -39,7 +39,14 @@
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets", "src/appConfig.json", "src/authConfig.json"],
"assets": [
"src/favicon.ico",
"src/assets",
"src/appConfig.json",
"src/authConfig.json",
"src/appConfig.stage.json",
"src/authConfig.stage.json"
],
"styles": [
"src/styles/core.scss",
{
@ -88,14 +95,6 @@
"outputHashing": "all",
"sourceMap": true
},
"stub-keycloak": {
"fileReplacements": [
{
"replace": "src/app/auth/keycloak/index.ts",
"with": "src/app/auth/keycloak/index.stub.ts"
}
]
},
"development": {
"buildOptimizer": false,
"optimization": false,
@ -103,6 +102,14 @@
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
},
"stage": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.stage.ts"
}
]
}
},
"defaultConfiguration": "production"
@ -116,8 +123,8 @@
"development": {
"browserTarget": "dashboard:build:development"
},
"stub-keycloak": {
"browserTarget": "dashboard:build:stub-keycloak"
"stage": {
"browserTarget": "dashboard:build:development,stage"
}
},
"defaultConfiguration": "development"
@ -130,7 +137,14 @@
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets", "src/appConfig.json", "src/authConfig.json"],
"assets": [
"src/favicon.ico",
"src/assets",
"src/appConfig.json",
"src/authConfig.json",
"src/appConfig.stage.json",
"src/authConfig.stage.json"
],
"styles": [
"src/styles/core.scss",
{

View File

@ -5,7 +5,7 @@
"scripts": {
"postinstall": "ngcc",
"start": "ng serve --port 8000",
"start-stub": "npm start -- --configuration=stub-keycloak",
"stage": "ng serve --port 8001 --configuration=stage",
"fix": "npm run lint-fix && npm run prettier-fix",
"build": "ng build --extra-webpack-config webpack.extra.js && npm run transloco:optimize",
"test": "ng test",

View File

@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import { Injectable, Injector } from '@angular/core';
import { ShopsService as ApiShopsService, Shop } from '@vality/swag-payments';
import { defer, Observable, Subject } from 'rxjs';
import { startWith, switchMapTo } from 'rxjs/operators';
import { defer, Observable, Subject, combineLatest } from 'rxjs';
import { startWith, switchMap } from 'rxjs/operators';
import { ContextService } from '@dsh/app/shared';
import { shareReplayRefCount } from '@dsh/operators';
import { createApi } from '../utils';
@ -11,14 +12,20 @@ import { createApi } from '../utils';
providedIn: 'root',
})
export class ShopsService extends createApi(ApiShopsService) {
shops$: Observable<Shop[]> = defer(() => this.reloadShops$).pipe(
startWith(null),
switchMapTo(this.getShops()),
shops$: Observable<Shop[]> = combineLatest([
this.contextService.organization$,
defer(() => this.reloadShops$).pipe(startWith(null)),
]).pipe(
switchMap(([{ party }]) => this.getShopsForParty({ partyID: party })),
shareReplayRefCount()
);
private reloadShops$ = new Subject<void>();
constructor(injector: Injector, private contextService: ContextService) {
super(injector);
}
reloadShops(): void {
this.reloadShops$.next();
}

View File

@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { first, map } from 'rxjs/operators';
import { KeycloakTokenInfoService } from '@dsh/app/shared/services/keycloak-token-info';
import { ContextService } from '@dsh/app/shared';
import { ApiExtension } from '../create-api';
@ -9,12 +9,12 @@ import { ApiExtension } from '../create-api';
providedIn: 'root',
})
export class PartyIdExtension implements ApiExtension {
constructor(private keycloakTokenInfoService: KeycloakTokenInfoService) {}
constructor(private contextService: ContextService) {}
selector() {
return this.keycloakTokenInfoService.partyID$.pipe(
return this.contextService.organization$.pipe(
first(),
map((partyID) => ({ partyID }))
map(({ party }) => ({ partyID: party }))
);
}
}

View File

@ -1,11 +1,11 @@
import { Component, Inject, OnInit } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import * as Sentry from '@sentry/angular';
import { first } from 'rxjs/operators';
import { first, map } from 'rxjs/operators';
import { ENV, Env } from '../environments';
import { BootstrapService } from './bootstrap.service';
import { KeycloakTokenInfoService } from './shared';
import { ContextService } from './shared';
@UntilDestroy()
@Component({
@ -19,12 +19,13 @@ export class AppComponent implements OnInit {
constructor(
private bootstrapService: BootstrapService,
@Inject(ENV) public env: Env,
private keycloakTokenInfoService: KeycloakTokenInfoService
private contextService: ContextService
) {}
ngOnInit(): void {
this.bootstrapService.bootstrap();
this.keycloakTokenInfoService.partyID$
this.contextService.organization$
.pipe(map(({ party }) => party))
.pipe(first(), untilDestroyed(this))
.subscribe((partyID) => Sentry.setUser({ id: partyID }));
}

View File

@ -1,19 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { ActivatedRouteSnapshot, Router, UrlTree } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { ErrorService } from '@dsh/app/shared';
import { KeycloakAuthGuard, KeycloakService } from './keycloak';
import { RoleAccessService } from './role-access.service';
import { RoleAccessName } from './types/role-access-name';
@Injectable()
export class AppAuthGuardService extends KeycloakAuthGuard {
constructor(protected router: Router, protected keycloakAngular: KeycloakService) {
constructor(
protected router: Router,
protected keycloakAngular: KeycloakService,
private errorService: ErrorService,
private roleAccessService: RoleAccessService
) {
super(router, keycloakAngular);
}
// eslint-disable-next-line @typescript-eslint/require-await
async isAccessAllowed(route: ActivatedRouteSnapshot): Promise<boolean> {
const isAccessAllowed = Array.isArray(this.roles) && route.data.roles.every((v) => this.roles.includes(v));
async isAccessAllowed(route: ActivatedRouteSnapshot): Promise<boolean | UrlTree> {
const isAccessAllowed = await firstValueFrom(
this.roleAccessService.isAccessAllowed(route.data.roles as RoleAccessName[])
);
if (!isAccessAllowed) {
console.error('Access is denied');
this.errorService.error('Access is denied', false);
return this.router.createUrlTree(['404']);
}
return isAccessAllowed;
}

View File

@ -1,10 +1,11 @@
import { NgModule } from '@angular/core';
import { AppAuthGuardService } from './app-auth-guard.service';
import { KeycloakAngularModule } from './keycloak';
import { IsAccessAllowedPipe } from './is-access-allowed.pipe';
@NgModule({
imports: [KeycloakAngularModule],
providers: [AppAuthGuardService],
declarations: [IsAccessAllowedPipe],
exports: [IsAccessAllowedPipe],
})
export class AuthModule {}

View File

@ -2,3 +2,8 @@ export * from './auth.module';
export * from './app-auth-guard.service';
// eslint-disable-next-line import/export
export * from './keycloak';
export * from './types/role-access-name';
export * from './types/role-access';
export * from './role-access-groups';
export * from './role-access.service';
export * from './utils/create-private-route';

View File

@ -0,0 +1,32 @@
import { AsyncPipe } from '@angular/common';
import { Pipe, PipeTransform, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { RoleAccessService } from './role-access.service';
import { RoleAccessName } from './types/role-access-name';
@Pipe({
name: 'isAccessAllowed',
})
export class IsAccessAllowedPipe implements PipeTransform, OnDestroy {
private asyncPipe: AsyncPipe;
constructor(private roleAccessService: RoleAccessService, ref: ChangeDetectorRef) {
this.asyncPipe = new AsyncPipe(ref);
}
ngOnDestroy() {
this.asyncPipe.ngOnDestroy();
}
transform(
roleAccessNames: RoleAccessName[] | keyof typeof RoleAccessName,
type: 'every' | 'some' = 'every'
): boolean {
return this.asyncPipe.transform(
this.roleAccessService.isAccessAllowed(
Array.isArray(roleAccessNames) ? roleAccessNames : [RoleAccessName[roleAccessNames]],
type
)
);
}
}

View File

@ -1,3 +0,0 @@
export * from './keycloak.module';
export * from './keycloak.service';
export * from './keycloak-auth-guard.service';

View File

@ -1,18 +0,0 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { KeycloakService } from './keycloak.service';
export abstract class KeycloakAuthGuard implements CanActivate {
protected authenticated: boolean;
protected roles: string[];
constructor(protected router: Router, protected keycloakAngular: KeycloakService) {}
// eslint-disable-next-line @typescript-eslint/require-await
async canActivate(_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot): Promise<boolean> {
return true;
}
abstract isAccessAllowed(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise<boolean>;
}

View File

@ -1,10 +0,0 @@
import { NgModule } from '@angular/core';
import { KeycloakService } from './keycloak.service';
@NgModule({
imports: [],
declarations: [],
providers: [KeycloakService],
})
export class KeycloakAngularModule {}

View File

@ -1,94 +0,0 @@
import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { KeycloakEvent, KeycloakOptions } from 'keycloak-angular';
import { KeycloakInstance, KeycloakLoginOptions, KeycloakProfile } from 'keycloak-js';
import { Observable, Observer, Subject } from 'rxjs';
import { STUB_USER } from './stub-user';
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable no-console */
@Injectable()
export class KeycloakService {
async init(_options: KeycloakOptions = {}): Promise<boolean> {
return true;
}
async login(_options: KeycloakLoginOptions = {}): Promise<void> {
console.log('login');
}
async logout(_redirectUri?: string): Promise<void> {
console.log('logout');
}
async register(_options: KeycloakLoginOptions = { action: 'register' }): Promise<void> {}
isUserInRole(_role: string): boolean {
return true;
}
getUserRoles(_allRoles: boolean = true): string[] {
return [];
}
async isLoggedIn(): Promise<boolean> {
return true;
}
isTokenExpired(_minValidity: number = 0): boolean {
return false;
}
async updateToken(_minValidity: number = 5): Promise<boolean> {
return true;
}
async loadUserProfile(_forceReload: boolean = false): Promise<KeycloakProfile> {
return STUB_USER;
}
async getToken(): Promise<string> {
return Math.random().toString();
}
getUsername(): string {
return STUB_USER.username;
}
clearToken(): void {}
addTokenToHeader(headersArg?: HttpHeaders): Observable<HttpHeaders> {
return Observable.create(async (observer: Observer<any>) => {
let headers = headersArg;
if (!headers) {
headers = new HttpHeaders();
}
try {
const token: string = await this.getToken();
headers = headers.set('Authorization', 'Bearer ' + token);
observer.next(headers);
observer.complete();
} catch (error) {
observer.error(error);
}
});
}
getKeycloakInstance(): KeycloakInstance {
return {} as KeycloakInstance;
}
get bearerExcludedUrls(): string[] {
return [];
}
get enableBearerInterceptor(): boolean {
return true;
}
get keycloakEvents$(): Subject<KeycloakEvent> {
return new Subject();
}
}

View File

@ -1,11 +0,0 @@
export const STUB_USER: Keycloak.KeycloakProfile = {
id: '1',
username: 'mock',
email: 'mock@rbkmoney.local',
firstName: 'Mock',
lastName: 'Money',
enabled: true,
emailVerified: true,
totp: true,
createdTimestamp: 1,
};

View File

@ -0,0 +1,68 @@
import { RoleId } from '@vality/swag-organizations';
import { RoleAccessGroup } from './types/role-access';
import { RoleAccessName } from './types/role-access-name';
export const ROLE_ACCESS_GROUPS: RoleAccessGroup[] = [
{
name: RoleAccessName.Payments,
children: [
{
name: RoleAccessName.ViewAnalytics,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.ViewInvoices,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewPayments,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewRefunds,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewPayouts,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant],
},
{
name: RoleAccessName.ApiKeys,
availableRoles: [RoleId.Administrator, RoleId.Integrator],
},
{
name: RoleAccessName.Reports,
availableRoles: [RoleId.Administrator, RoleId.Accountant],
},
{
name: RoleAccessName.Webhooks,
availableRoles: [RoleId.Administrator, RoleId.Integrator],
},
{
name: RoleAccessName.CreateInvoice,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.PaymentLinks,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.CreateRefund,
availableRoles: [RoleId.Administrator, RoleId.Accountant],
},
],
},
{
name: RoleAccessName.Wallets,
availableRoles: [RoleId.Administrator, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.Claims,
availableRoles: [RoleId.Administrator],
},
{
name: RoleAccessName.ManageOrganizations,
availableRoles: [RoleId.Administrator],
},
];

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { ContextService } from '@dsh/app/shared';
import { ROLE_ACCESS_GROUPS } from './role-access-groups';
import { RoleAccess } from './types/role-access';
import { RoleAccessName } from './types/role-access-name';
const ROLE_ACCESSES_OBJECT = Object.fromEntries(
ROLE_ACCESS_GROUPS.flatMap((r) => [r, ...(r.children || [])] as RoleAccess[]).map((r) => [r.name, r.availableRoles])
);
@Injectable({
providedIn: 'root',
})
export class RoleAccessService {
constructor(private contextService: ContextService) {}
isAccessAllowed(roleAccessNames: RoleAccessName[], type: 'every' | 'some' = 'every'): Observable<boolean> {
if (!roleAccessNames.length) return of(true);
return this.contextService.member$.pipe(
first(),
map((member) => {
const memberRoles = member.roles.map((r) => r.roleId);
return roleAccessNames[type]((access) =>
ROLE_ACCESSES_OBJECT[access]?.some((role) => memberRoles.includes(role))
);
})
);
}
}

View File

@ -0,0 +1,22 @@
export enum RoleAccessName {
Payments,
Reports,
Webhooks,
ApiKeys,
PaymentLinks,
ViewAnalytics,
ViewPayments,
ViewPayouts,
ViewInvoices,
CreateInvoice,
ViewRefunds,
CreateRefund,
Wallets,
Claims,
ManageOrganizations,
}

View File

@ -0,0 +1,13 @@
import { RoleId } from '@vality/swag-organizations';
import { Overwrite } from 'utility-types';
import { RoleAccessName } from './role-access-name';
export interface RoleAccess {
name: RoleAccessName;
availableRoles: RoleId[];
}
export interface RoleAccessGroup extends Overwrite<RoleAccess, Partial<Pick<RoleAccess, 'availableRoles'>>> {
children?: RoleAccess[];
}

View File

@ -0,0 +1,14 @@
import { Route } from '@angular/router';
import { RoleAccessName, AppAuthGuardService } from '@dsh/app/auth';
export function createPrivateRoute(route: Route, roles: RoleAccessName[]) {
return {
...route,
canActivate: [...(route?.canActivate ?? []), AppAuthGuardService],
data: {
...(route?.data ?? {}),
roles,
},
};
}

View File

@ -37,12 +37,12 @@ export class BootstrapService {
private getBootstrapped(): Observable<boolean> {
return concat(this.initOrganization(), this.initShop()).pipe(
takeLast(1),
catchError((err) => {
this.errorService.error(
new CommonError(this.transloco.translate('app.errors.bootstrapAppFailed', null, 'components'))
);
return throwError(err);
})
catchError((err) =>
this.transloco.selectTranslate<string>('app.errors.bootstrapAppFailed', null, 'components').pipe(
tap((msg) => this.errorService.error(new CommonError(msg))),
switchMap(() => throwError(err))
)
)
);
}

View File

@ -4,7 +4,9 @@
fxLayoutGap="24px"
>
<ng-container>
<span class="dsh-body-2" *ngIf="activeOrg$ | async">{{ (activeOrg$ | async)?.name }}</span>
<dsh-menu-item class="dsh-body-2" *ngIf="activeOrg$ | async as activeOrg" (click)="toActiveOrg(activeOrg.id)">{{
activeOrg.name
}}</dsh-menu-item>
<dsh-menu-item (click)="selectActiveOrg()">{{ t('selectActiveOrg') }}</dsh-menu-item>
<dsh-menu-item (click)="openOrgList()">{{ t('orgList') }}</dsh-menu-item>
</ng-container>

View File

@ -77,4 +77,9 @@ export class UserComponent {
void this.router.navigate(['organization-section', 'organizations']);
this.selected.emit();
}
toActiveOrg(activeOrg: string): void {
void this.router.navigate(['organization-section', 'organizations'], { fragment: activeOrg });
this.selected.emit();
}
}

View File

@ -1,3 +1,4 @@
import { environment } from '../environments';
import { KeycloakService } from './auth/keycloak';
import { ConfigService } from './config';
import { IconsService } from './icons';
@ -15,12 +16,12 @@ export const initializer =
) =>
() =>
Promise.all([
configService.init({ configUrl: '/appConfig.json' }).then(() =>
configService.init({ configUrl: environment.appConfigPath }).then(() =>
Promise.all([
themeManager.init(),
initSentry(configService.sentryDsn),
keycloakService.init({
config: '/authConfig.json',
config: environment.authConfigPath,
initOptions: {
onLoad: 'login-required',
checkLoginIframe: true,

View File

@ -1,24 +1,29 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RoleAccessName, createPrivateRoute } from '@dsh/app/auth';
import { ClaimSectionComponent } from './claim-section.component';
const CLAIM_SECTION_ROUTES: Routes = [
{
path: '',
component: ClaimSectionComponent,
children: [
{
path: 'claims',
loadChildren: () => import('./claims/claims.module').then((m) => m.ClaimsModule),
},
{
path: 'claims/:claimId',
loadChildren: () => import('./claim/claim.module').then((m) => m.ClaimModule),
},
{ path: '', redirectTo: 'claims', pathMatch: 'full' },
],
},
createPrivateRoute(
{
path: '',
component: ClaimSectionComponent,
children: [
{
path: 'claims',
loadChildren: () => import('./claims/claims.module').then((m) => m.ClaimsModule),
},
{
path: 'claims/:claimId',
loadChildren: () => import('./claim/claim.module').then((m) => m.ClaimModule),
},
{ path: '', redirectTo: 'claims', pathMatch: 'full' },
],
},
[RoleAccessName.Claims]
),
];
@NgModule({

View File

@ -1,70 +0,0 @@
import { RoleId } from '@vality/swag-organizations';
import { RoleAccess } from './types/role-access';
import { RoleAccessName } from './types/role-access-name';
export const ROLES_ACCESSES: RoleAccess[] = [
{
name: RoleAccessName.Payments,
isHeader: true,
},
{
name: RoleAccessName.ViewAnalytics,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.ViewInvoices,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewPayments,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewRefunds,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.ViewPayouts,
availableRoles: [RoleId.Administrator, RoleId.Manager, RoleId.Accountant],
},
{
name: RoleAccessName.ViewApiKey,
availableRoles: [RoleId.Administrator, RoleId.Integrator],
},
{
name: RoleAccessName.ManageReports,
availableRoles: [RoleId.Administrator, RoleId.Accountant],
},
{
name: RoleAccessName.ManageWebhooks,
availableRoles: [RoleId.Administrator, RoleId.Integrator],
},
{
name: RoleAccessName.CreateInvoice,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.CreatePaymentLink,
availableRoles: [RoleId.Administrator, RoleId.Manager],
},
{
name: RoleAccessName.CreateRefund,
availableRoles: [RoleId.Administrator, RoleId.Accountant],
},
{
name: RoleAccessName.Wallets,
isHeader: true,
availableRoles: [RoleId.Administrator, RoleId.Accountant, RoleId.Integrator],
},
{
name: RoleAccessName.Claims,
isHeader: true,
availableRoles: [RoleId.Administrator],
},
{
name: RoleAccessName.ManageOrganizations,
isHeader: true,
availableRoles: [RoleId.Administrator],
},
];

View File

@ -5,14 +5,18 @@ import { FormBuilder } from '@ngneat/reactive-forms';
import { RoleId } from '@vality/swag-organizations';
import { OrganizationsDictionaryService } from '@dsh/api/organizations';
import { RoleAccess, ROLE_ACCESS_GROUPS } from '@dsh/app/auth';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
import { ROLE_PRIORITY_DESC } from '@dsh/app/shared/components/organization-roles/utils/sort-role-ids';
import { ROLES_ACCESSES } from './roles-accesses';
import { RoleAccessesDictionaryService } from './services/role-accesses-dictionary.service';
import { SelectRoleDialogResult } from './types/select-role-dialog-result';
import { SelectRoleDialogData } from './types/selected-role-dialog-data';
interface FlatRoleAccess extends RoleAccess {
isHeader: boolean;
}
@Component({
selector: 'dsh-select-role-dialog',
templateUrl: 'select-role-dialog.component.html',
@ -21,7 +25,9 @@ import { SelectRoleDialogData } from './types/selected-role-dialog-data';
})
export class SelectRoleDialogComponent {
roleControl = this.fb.control<RoleId>(null, Validators.required);
accesses = ROLES_ACCESSES;
accesses: FlatRoleAccess[] = ROLE_ACCESS_GROUPS.map((r) => ({ ...r, isHeader: true })).flatMap(
(r) => [r, ...(r.children || [])] as FlatRoleAccess[]
);
roleIdDict$ = this.organizationsDictionaryService.roleId$;
roleAccessDict$ = this.roleAccessesDictionaryService.roleAccessDict$;
get rowsGridTemplateColumns() {

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco';
import { map } from 'rxjs/operators';
import { RoleAccessName } from '../types/role-access-name';
import { RoleAccessName } from '@dsh/app/auth';
@Injectable({
providedIn: 'root',
@ -11,29 +11,81 @@ export class RoleAccessesDictionaryService {
roleAccessDict$ = this.t.selectTranslation('organization-section').pipe(
map(
(): Record<RoleAccessName, string> => ({
claims: this.t.translate('roleAccessesDictionary.claims', null, 'organization-section'),
createInvoice: this.t.translate('roleAccessesDictionary.createInvoice', null, 'organization-section'),
createPaymentLink: this.t.translate(
[RoleAccessName.Claims]: this.t.translate(
'roleAccessesDictionary.claims',
null,
'organization-section'
),
[RoleAccessName.CreateInvoice]: this.t.translate(
'roleAccessesDictionary.createInvoice',
null,
'organization-section'
),
[RoleAccessName.PaymentLinks]: this.t.translate(
'roleAccessesDictionary.createPaymentLink',
null,
'organization-section'
),
createRefund: this.t.translate('roleAccessesDictionary.createRefund', null, 'organization-section'),
manageOrganizations: this.t.translate(
[RoleAccessName.CreateRefund]: this.t.translate(
'roleAccessesDictionary.createRefund',
null,
'organization-section'
),
[RoleAccessName.ManageOrganizations]: this.t.translate(
'roleAccessesDictionary.manageOrganizations',
null,
'organization-section'
),
manageReports: this.t.translate('roleAccessesDictionary.manageReports', null, 'organization-section'),
manageWebhooks: this.t.translate('roleAccessesDictionary.manageWebhooks', null, 'organization-section'),
payments: this.t.translate('roleAccessesDictionary.payments', null, 'organization-section'),
viewAnalytics: this.t.translate('roleAccessesDictionary.viewAnalytics', null, 'organization-section'),
viewApiKey: this.t.translate('roleAccessesDictionary.viewApiKey', null, 'organization-section'),
viewInvoices: this.t.translate('roleAccessesDictionary.viewInvoices', null, 'organization-section'),
viewPayments: this.t.translate('roleAccessesDictionary.viewPayments', null, 'organization-section'),
viewPayouts: this.t.translate('roleAccessesDictionary.viewPayouts', null, 'organization-section'),
viewRefunds: this.t.translate('roleAccessesDictionary.viewRefunds', null, 'organization-section'),
wallets: this.t.translate('roleAccessesDictionary.wallets', null, 'organization-section'),
[RoleAccessName.Reports]: this.t.translate(
'roleAccessesDictionary.manageReports',
null,
'organization-section'
),
[RoleAccessName.Webhooks]: this.t.translate(
'roleAccessesDictionary.manageWebhooks',
null,
'organization-section'
),
[RoleAccessName.Payments]: this.t.translate(
'roleAccessesDictionary.payments',
null,
'organization-section'
),
[RoleAccessName.ViewAnalytics]: this.t.translate(
'roleAccessesDictionary.viewAnalytics',
null,
'organization-section'
),
[RoleAccessName.ApiKeys]: this.t.translate(
'roleAccessesDictionary.viewApiKey',
null,
'organization-section'
),
[RoleAccessName.ViewInvoices]: this.t.translate(
'roleAccessesDictionary.viewInvoices',
null,
'organization-section'
),
[RoleAccessName.ViewPayments]: this.t.translate(
'roleAccessesDictionary.viewPayments',
null,
'organization-section'
),
[RoleAccessName.ViewPayouts]: this.t.translate(
'roleAccessesDictionary.viewPayouts',
null,
'organization-section'
),
[RoleAccessName.ViewRefunds]: this.t.translate(
'roleAccessesDictionary.viewRefunds',
null,
'organization-section'
),
[RoleAccessName.Wallets]: this.t.translate(
'roleAccessesDictionary.wallets',
null,
'organization-section'
),
})
)
);

View File

@ -1,7 +0,0 @@
import { RoleId } from '@vality/swag-organizations';
export interface RoleAccessItem {
name: string;
isHeader?: boolean;
availableRoles?: RoleId[];
}

View File

@ -1,17 +0,0 @@
export enum RoleAccessName {
Payments = 'payments',
ViewAnalytics = 'viewAnalytics',
ViewInvoices = 'viewInvoices',
ViewPayments = 'viewPayments',
ViewRefunds = 'viewRefunds',
ViewPayouts = 'viewPayouts',
ViewApiKey = 'viewApiKey',
ManageReports = 'manageReports',
ManageWebhooks = 'manageWebhooks',
CreateInvoice = 'createInvoice',
CreatePaymentLink = 'createPaymentLink',
CreateRefund = 'createRefund',
Wallets = 'wallets',
Claims = 'claims',
ManageOrganizations = 'manageOrganizations',
}

View File

@ -1,7 +0,0 @@
import { RoleId } from '@vality/swag-organizations';
export interface RoleAccess {
name: string;
isHeader?: boolean;
availableRoles?: RoleId[];
}

View File

@ -3,6 +3,7 @@ import { MatDialogRef } from '@angular/material/dialog';
import { FormBuilder } from '@ngneat/reactive-forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { BehaviorSubject, switchMap } from 'rxjs';
import { first } from 'rxjs/operators';
import { OrgsService } from '@dsh/api/organizations';
import { KeycloakTokenInfoService } from '@dsh/app/shared';
@ -32,8 +33,9 @@ export class CreateOrganizationDialogComponent {
@inProgressTo('inProgress$')
create() {
return this.keycloakTokenInfoService.partyID$
return this.keycloakTokenInfoService.userID$
.pipe(
first(),
switchMap((owner) =>
this.organizationsService.createOrg({
organization: {

View File

@ -7,7 +7,7 @@ import { filter, pluck, switchMap } from 'rxjs/operators';
import { OrgsService } from '@dsh/api/organizations';
import { BaseDialogResponseStatus } from '@dsh/app/shared/components/dialog/base-dialog';
import { ErrorService, NotificationService } from '@dsh/app/shared/services';
import { ErrorService, NotificationService, ContextService } from '@dsh/app/shared/services';
import { FetchOrganizationsService } from '@dsh/app/shared/services/fetch-organizations';
import { OrganizationManagementService } from '@dsh/app/shared/services/organization-management/organization-management.service';
import { ConfirmActionDialogComponent, ConfirmActionDialogResult } from '@dsh/components/popups';
@ -29,7 +29,7 @@ export class OrganizationComponent implements OnChanges {
@Output() changed = new EventEmitter<void>();
member$ = this.organizationManagementService.currentMember$;
member$ = this.contextService.member$;
membersCount$ = this.organizationManagementService.members$.pipe(pluck('length'));
hasAdminAccess$ = this.organizationManagementService.hasAdminAccess$;
isOwner$ = this.organizationManagementService.isOrganizationOwner$;
@ -40,7 +40,8 @@ export class OrganizationComponent implements OnChanges {
private dialog: MatDialog,
private notificationService: NotificationService,
private errorService: ErrorService,
private fetchOrganizationsService: FetchOrganizationsService
private fetchOrganizationsService: FetchOrganizationsService,
private contextService: ContextService
) {}
ngOnChanges({ organization }: ComponentChanges<OrganizationComponent>) {

View File

@ -1,4 +1,11 @@
<div>404</div>
<button *transloco="let t; scope: 'components'; read: 'components.pageNotFound'" (click)="back()">
{{ t('back') }}
</button>
<div fxLayout="column" fxLayoutGap="16px" fxLayoutAlign="center center">
<div class="dsh-display-1">404</div>
<button
dsh-button
color="primary"
*transloco="let t; scope: 'components'; read: 'components.pageNotFound'"
(click)="back()"
>
{{ t('back') }}
</button>
</div>

View File

@ -1,14 +1,14 @@
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'dsh-page-not-found',
templateUrl: 'page-not-found.component.html',
})
export class PageNotFoundComponent {
constructor(private router: Router) {}
constructor(private location: Location) {}
back() {
this.router.navigate(['']);
this.location.back();
}
}

View File

@ -1,13 +1,16 @@
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { ButtonModule } from '@dsh/components/buttons';
import { PageNotFoundRoutingModule } from './page-not-found-routing.module';
import { PageNotFoundComponent } from './page-not-found.component';
@NgModule({
declarations: [PageNotFoundComponent],
imports: [RouterModule, PageNotFoundRoutingModule, TranslocoModule],
imports: [RouterModule, PageNotFoundRoutingModule, TranslocoModule, FlexModule, ButtonModule],
exports: [PageNotFoundComponent],
})
export class PageNotFoundModule {}

View File

@ -1,13 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RoleAccessName, createPrivateRoute } from '@dsh/app/auth';
import { AnalyticsComponent } from './analytics.component';
const OPERATIONS_ROUTES: Routes = [
{
path: '',
component: AnalyticsComponent,
},
createPrivateRoute(
{
path: '',
component: AnalyticsComponent,
},
[RoleAccessName.ViewAnalytics]
),
];
@NgModule({

View File

@ -1,6 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { createPrivateRoute, RoleAccessName } from '@dsh/app/auth';
import { IntegrationsComponent } from './integrations.component';
const ROUTES: Routes = [
@ -8,18 +10,27 @@ const ROUTES: Routes = [
path: '',
component: IntegrationsComponent,
children: [
{
path: 'webhooks',
loadChildren: () => import('./webhooks').then((m) => m.WebhooksModule),
},
{
path: 'payment-link',
loadChildren: () => import('./payment-link').then((m) => m.PaymentLinkModule),
},
{
path: 'api-key',
loadChildren: () => import('./api-key').then((m) => m.ApiKeyModule),
},
createPrivateRoute(
{
path: 'webhooks',
loadChildren: () => import('./webhooks').then((m) => m.WebhooksModule),
},
[RoleAccessName.Webhooks]
),
createPrivateRoute(
{
path: 'payment-link',
loadChildren: () => import('./payment-link').then((m) => m.PaymentLinkModule),
},
[RoleAccessName.PaymentLinks]
),
createPrivateRoute(
{
path: 'api-key',
loadChildren: () => import('./api-key').then((m) => m.ApiKeyModule),
},
[RoleAccessName.ApiKeys]
),
{
path: '',
redirectTo: 'payment-link',

View File

@ -8,7 +8,9 @@
</div>
<div fxLayout="column" fxLayout.gt-sm="row" fxLayoutGap="24px">
<ng-container *ngIf="invoice.status === 'unpaid'">
<button dsh-button color="accent" (click)="createPaymentLink()">{{ t('createPaymentLink') }}</button>
<button dsh-button color="accent" *ngIf="'PaymentLinks' | isAccessAllowed" (click)="createPaymentLink()">
{{ t('createPaymentLink') }}
</button>
<button dsh-button color="warn" (click)="cancelInvoice()">{{ t('cancelInvoice') }}</button>
</ng-container>
<ng-container *ngIf="invoice.status === 'paid'">

View File

@ -22,7 +22,6 @@ import { FulfillInvoiceService } from '../../fulfill-invoice';
})
export class InvoiceActionsComponent {
@Input() invoice: Invoice;
@Output() refreshData = new EventEmitter<void>();
constructor(

View File

@ -7,6 +7,7 @@ import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import {
InvoiceDetailsModule as InvoiceInvoiceDetailsModule,
PaymentDetailsModule,
@ -49,6 +50,7 @@ import { TaxModeToTaxRatePipe } from './pipes/tax-mode-to-tax-rate/tax-mode-to-t
RouterModule,
MatIconModule,
AmountCurrencyModule,
AuthModule,
],
declarations: [
InvoiceDetailsComponent,

View File

@ -6,14 +6,16 @@
gdAlignRows="space-between start"
gdGap="32px"
>
<button
*transloco="let i; scope: 'payment-section'; read: 'paymentSection.invoices'"
dsh-button
color="accent"
(click)="create()"
>
{{ i('createButton') }}
</button>
<ng-container *ngIf="'CreateInvoice' | isAccessAllowed">
<button
*transloco="let i; scope: 'payment-section'; read: 'paymentSection.invoices'"
dsh-button
color="accent"
(click)="create()"
>
{{ i('createButton') }}
</button>
</ng-container>
<dsh-invoices-search-filters
gdRow.gt-sm="1"
[initParams]="params$ | async"

View File

@ -14,6 +14,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { InvoiceDetailsModule } from '@dsh/app/shared/components';
import { ButtonModule } from '@dsh/components/buttons';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
@ -60,6 +61,7 @@ import { InvoicesComponent } from './invoices.component';
InvoiceDetailsModule,
InvoicesListModule,
ShowMorePanelModule,
AuthModule,
],
declarations: [InvoicesComponent],
})

View File

@ -1,6 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { createPrivateRoute, RoleAccessName } from '@dsh/app/auth';
import { OperationsComponent } from './operations.component';
const OPERATIONS_ROUTES: Routes = [
@ -8,18 +10,27 @@ const OPERATIONS_ROUTES: Routes = [
path: '',
component: OperationsComponent,
children: [
{
path: 'payments',
loadChildren: () => import('./payments/payments.module').then((mod) => mod.PaymentsModule),
},
{
path: 'refunds',
loadChildren: () => import('./refunds/refunds.module').then((mod) => mod.RefundsModule),
},
{
path: 'invoices',
loadChildren: () => import('./invoices/invoices.module').then((mod) => mod.InvoicesModule),
},
createPrivateRoute(
{
path: 'payments',
loadChildren: () => import('./payments/payments.module').then((mod) => mod.PaymentsModule),
},
[RoleAccessName.ViewPayments]
),
createPrivateRoute(
{
path: 'refunds',
loadChildren: () => import('./refunds/refunds.module').then((mod) => mod.RefundsModule),
},
[RoleAccessName.ViewRefunds]
),
createPrivateRoute(
{
path: 'invoices',
loadChildren: () => import('./invoices/invoices.module').then((mod) => mod.InvoicesModule),
},
[RoleAccessName.ViewInvoices]
),
{
path: '',
redirectTo: 'payments',

View File

@ -7,7 +7,13 @@
<div>
{{ t('title') }}
</div>
<button dsh-button color="accent" [disabled]="!isRefundAvailable" (click)="createRefund()">
<button
*ngIf="'CreateRefund' | isAccessAllowed"
dsh-button
color="accent"
[disabled]="!isRefundAvailable"
(click)="createRefund()"
>
{{ t('createRefund.title') }}
</button>
</div>

View File

@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { ButtonModule } from '@dsh/components/buttons';
import { CreateRefundModule } from './create-refund';
@ -10,7 +11,15 @@ import { RefundsListModule } from './refunds-list';
import { RefundsComponent } from './refunds.component';
@NgModule({
imports: [CommonModule, TranslocoModule, FlexLayoutModule, ButtonModule, CreateRefundModule, RefundsListModule],
imports: [
CommonModule,
TranslocoModule,
FlexLayoutModule,
ButtonModule,
CreateRefundModule,
RefundsListModule,
AuthModule,
],
declarations: [RefundsComponent],
exports: [RefundsComponent],
})

View File

@ -1,6 +1,8 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { createPrivateRoute, RoleAccessName } from '@dsh/app/auth';
import { PaymentSectionComponent } from './payment-section.component';
const PAYMENT_SECTION_ROUTES: Routes = [
@ -21,22 +23,31 @@ const PAYMENT_SECTION_ROUTES: Routes = [
path: 'shops',
loadChildren: () => import('./shops/shops.module').then((m) => m.ShopsModule),
},
{
path: 'analytics',
loadChildren: () => import('./analytics/analytics.module').then((m) => m.AnalyticsModule),
},
createPrivateRoute(
{
path: 'analytics',
loadChildren: () => import('./analytics/analytics.module').then((m) => m.AnalyticsModule),
},
[RoleAccessName.ViewAnalytics]
),
{
path: 'operations',
loadChildren: () => import('./operations/operations.module').then((m) => m.OperationsModule),
},
{
path: 'reports',
loadChildren: () => import('./reports/reports.module').then((m) => m.ReportsModule),
},
{
path: 'payouts',
loadChildren: () => import('./payouts/payouts.module').then((m) => m.PayoutsModule),
},
createPrivateRoute(
{
path: 'reports',
loadChildren: () => import('./reports/reports.module').then((m) => m.ReportsModule),
},
[RoleAccessName.Reports]
),
createPrivateRoute(
{
path: 'payouts',
loadChildren: () => import('./payouts/payouts.module').then((m) => m.PayoutsModule),
},
[RoleAccessName.ViewPayouts]
),
{
path: 'integrations',
loadChildren: () => import('./integrations/integrations.module').then((m) => m.IntegrationsModule),

View File

@ -2,16 +2,19 @@
<dsh-no-shops-alert *ngIf="noShops$ | async" (action)="navigateToShops()"></dsh-no-shops-alert>
<dsh-route-navbar-layout [routeName]="activeSection$ | async">
<div fxLayout="row" fxLayout.gt-sm="column">
<dsh-navbar-item
*ngFor="let item of navbarItemConfig$ | async"
[routerLink]="item.routerLink"
routerLinkActive
#rla="routerLinkActive"
[active]="rla.isActive"
[icon]="item.icon"
(activeChange)="setActiveSection($event, item)"
>{{ item.label }}</dsh-navbar-item
>
<ng-container *ngFor="let item of navbarItemConfig$ | async">
<dsh-navbar-item
*ngIf="item.roles | isAccessAllowed: 'some'"
[routerLink]="item.routerLink"
routerLinkActive
#rla="routerLinkActive"
[active]="rla.isActive"
[icon]="item.icon"
(activeChange)="setActiveSection($event, item)"
>
{{ item.label }}
</dsh-navbar-item>
</ng-container>
<dsh-navbar-item
*transloco="let t; scope: 'payment-section'; read: 'paymentSection.paymentSection.nav'"
withToggle="true"

View File

@ -4,6 +4,7 @@ import { FlexLayoutModule } from '@angular/flex-layout';
import { MatIconModule } from '@angular/material/icon';
import { TranslocoModule, TRANSLOCO_SCOPE } from '@ngneat/transloco';
import { AuthModule } from '@dsh/app/auth';
import { RouteNavbarLayoutModule } from '@dsh/app/shared/components/route-navbar-layout';
import { NavbarItemModule } from '@dsh/components/navigation';
@ -23,6 +24,7 @@ import { PaymentSectionComponent } from './payment-section.component';
RouteNavbarLayoutModule,
NavbarItemModule,
NoShopsAlertModule,
AuthModule,
],
declarations: [PaymentSectionComponent],
exports: [PaymentSectionComponent],

View File

@ -1,3 +1,4 @@
import { RoleAccessName } from '@dsh/app/auth';
import { BootstrapIconName } from '@dsh/components/indicators';
export enum NavbarRouterLink {
@ -13,6 +14,7 @@ export interface NavbarItemConfig {
routerLink: NavbarRouterLink;
icon: BootstrapIconName;
label: string;
roles: RoleAccessName[];
}
export const toNavbarItemConfig = ({
@ -30,30 +32,36 @@ export const toNavbarItemConfig = ({
routerLink: NavbarRouterLink.Shops,
icon: BootstrapIconName.Shop,
label: shops,
roles: [],
},
{
routerLink: NavbarRouterLink.Analytics,
icon: BootstrapIconName.PieChart,
label: analytics,
roles: [RoleAccessName.ViewAnalytics],
},
{
routerLink: NavbarRouterLink.Operations,
icon: BootstrapIconName.LayoutTextSidebarReverse,
label: operations,
roles: [RoleAccessName.ViewPayments, RoleAccessName.ViewInvoices, RoleAccessName.ViewRefunds],
},
{
routerLink: NavbarRouterLink.Payouts,
icon: BootstrapIconName.ArrowRightCircle,
label: payouts,
roles: [RoleAccessName.ViewPayouts],
},
{
routerLink: NavbarRouterLink.Reports,
icon: BootstrapIconName.FileText,
label: reports,
roles: [],
},
{
routerLink: NavbarRouterLink.Integrations,
icon: BootstrapIconName.Plug,
label: integrations,
roles: [RoleAccessName.PaymentLinks, RoleAccessName.ApiKeys, RoleAccessName.Webhooks],
},
];

View File

@ -1,11 +1,16 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Organization } from '@vality/swag-organizations';
import { Observable, ReplaySubject, EMPTY, concat, defer } from 'rxjs';
import { distinctUntilChanged, switchMap, shareReplay, catchError, map } from 'rxjs/operators';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Organization, Member, RoleId } from '@vality/swag-organizations';
import { Observable, ReplaySubject, EMPTY, concat, defer, combineLatest, of, throwError } from 'rxjs';
import { switchMap, shareReplay, catchError, map, tap } from 'rxjs/operators';
import { OrgsService } from '@dsh/api/organizations';
import { OrgsService, MembersService } from '@dsh/api/organizations';
import { ErrorService } from '../error';
import { KeycloakTokenInfoService } from '../keycloak-token-info';
@UntilDestroy()
@Injectable({
providedIn: 'root',
})
@ -15,28 +20,55 @@ export class ContextService {
map(({ organizationId }) => organizationId),
catchError((err) => {
if (err instanceof HttpErrorResponse && err.status === 404)
return this.organizationsService
.listOrgMembership({ limit: 1 })
.pipe(map(({ result }) => result[0].id));
return this.organizationsService.listOrgMembership({ limit: 1 }).pipe(
map(({ result }) => result[0].id),
tap((id) => this.switchOrganization(id)),
switchMap(() => EMPTY)
);
console.error(err);
return EMPTY;
})
),
defer(() => this.switchOrganization$)
defer(() => this.switchOrganization$).pipe(
switchMap((organizationId) =>
this.organizationsService
.switchContext({ organizationSwitchRequest: { organizationId } })
.pipe(map(() => organizationId))
)
)
).pipe(
distinctUntilChanged(),
switchMap((organizationId) =>
this.organizationsService
.switchContext({ organizationSwitchRequest: { organizationId } })
.pipe(map(() => organizationId))
),
switchMap((orgId) => this.organizationsService.getOrg({ orgId })),
shareReplay({ refCount: true, bufferSize: 1 })
untilDestroyed(this),
shareReplay(1)
);
member$ = combineLatest([this.organization$, this.keycloakTokenInfoService.userID$]).pipe(
switchMap(([{ id: orgId }, userId]) =>
this.membersService.getOrgMember({ orgId, userId }).pipe(
catchError((error) => {
if (error instanceof HttpErrorResponse && error.status === 404) {
return of<Member>({
id: userId,
userEmail: '',
roles: [{ id: null, roleId: RoleId.Administrator }],
});
}
this.errorService.error(error);
return throwError(error);
})
)
),
untilDestroyed(this),
shareReplay(1)
);
private switchOrganization$ = new ReplaySubject<string>(1);
constructor(private organizationsService: OrgsService) {}
constructor(
private organizationsService: OrgsService,
private membersService: MembersService,
private keycloakTokenInfoService: KeycloakTokenInfoService,
private errorService: ErrorService
) {}
switchOrganization(organizationId: string): void {
this.switchOrganization$.next(organizationId);

View File

@ -2,16 +2,15 @@ import { Injectable } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import jwt_decode, { JwtPayload } from 'jwt-decode';
import { KeycloakService } from 'keycloak-angular';
import { from, Observable, defer } from 'rxjs';
import { map, pluck, shareReplay } from 'rxjs/operators';
import { Observable, defer, from } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
@UntilDestroy()
@Injectable({
providedIn: 'root',
})
export class KeycloakTokenInfoService {
// Party ID & User ID
partyID$: Observable<string> = defer(() => this.decoded$).pipe(pluck('sub'));
userID$: Observable<string> = defer(() => this.decoded$).pipe(map(({ sub }) => sub));
private decoded$ = from(this.keycloakService.getToken()).pipe(
map((token) => jwt_decode<JwtPayload>(token)),

View File

@ -1,43 +1,27 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Member, Organization, RoleId } from '@vality/swag-organizations';
import { combineLatest, defer, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, map, pluck, shareReplay, switchMap } from 'rxjs/operators';
import { combineLatest, defer, Observable, ReplaySubject } from 'rxjs';
import { map, pluck, shareReplay, switchMap } from 'rxjs/operators';
import { MembersService } from '@dsh/api/organizations';
import { ErrorService, KeycloakTokenInfoService } from '@dsh/app/shared';
import { ContextService } from '@dsh/app/shared';
import { Initializable } from '@dsh/app/shared/types';
import { SHARE_REPLAY_CONF } from '@dsh/operators';
@Injectable()
export class OrganizationManagementService implements Initializable {
currentMember$: Observable<Member> = defer(() =>
combineLatest([this.organization$, this.keycloakTokenInfoService.partyID$])
).pipe(
switchMap(([{ id: orgId }, userId]) =>
this.membersService.getOrgMember({ orgId, userId }).pipe(
catchError((error) => {
if (!(error instanceof HttpErrorResponse && error.status === 404)) {
this.errorService.error(error);
}
return of<Member>({ id: userId, userEmail: '', roles: [] });
})
)
),
shareReplay(SHARE_REPLAY_CONF)
);
members$: Observable<Member[]> = defer(() => this.organization$).pipe(
switchMap(({ id }) => this.membersService.listOrgMembers({ orgId: id })),
pluck('result'),
shareReplay(SHARE_REPLAY_CONF)
);
isOrganizationOwner$: Observable<boolean> = defer(() =>
combineLatest([this.organization$, this.keycloakTokenInfoService.partyID$])
combineLatest([this.organization$, this.contextService.organization$.pipe(pluck('party'))])
).pipe(
map(([{ owner }, id]) => owner === id),
shareReplay(SHARE_REPLAY_CONF)
);
isOrganizationAdmin$: Observable<boolean> = this.currentMember$.pipe(
isOrganizationAdmin$: Observable<boolean> = this.contextService.member$.pipe(
map((member) => member.roles.findIndex((r) => r.roleId === RoleId.Administrator) !== -1),
shareReplay(SHARE_REPLAY_CONF)
);
@ -50,11 +34,7 @@ export class OrganizationManagementService implements Initializable {
private organization$ = new ReplaySubject<Organization>();
constructor(
private membersService: MembersService,
private keycloakTokenInfoService: KeycloakTokenInfoService,
private errorService: ErrorService
) {}
constructor(private membersService: MembersService, private contextService: ContextService) {}
init(organization: Organization) {
this.organization$.next(organization);

View File

@ -1,3 +1,3 @@
export * from './section-links.module';
export * from './section-links.service';
export * from './model';
export * from './types';

View File

@ -4,27 +4,41 @@ import { combineLatest, Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { WalletsService } from '@dsh/api/wallet';
import { RoleAccessName, RoleAccessService } from '@dsh/app/auth';
import { SectionLink } from './model';
import { createLinks } from './utils';
import { SectionLink } from './types';
@Injectable()
export class SectionsLinksService {
sectionLinks$: Observable<SectionLink[]> = combineLatest([
this.walletsService.hasWallets$,
this.roleAccessService.isAccessAllowed([RoleAccessName.Wallets]),
this.roleAccessService.isAccessAllowed([RoleAccessName.Claims]),
this.transloco.selectTranslation('services'),
]).pipe(
map(([hasWallets]) => createLinks(this.getLabels(), hasWallets)),
map(([hasWallets, allowWallets, allowClaims]) =>
[
{
label: this.transloco.translate('sectionsLinks.links.payments', null, 'services'),
path: `/payment-section`,
},
hasWallets &&
allowWallets && {
label: this.transloco.translate('sectionsLinks.links.wallets', null, 'services'),
path: '/wallet-section',
},
allowClaims && {
label: this.transloco.translate('sectionsLinks.links.claims', null, 'services'),
path: '/claim-section',
},
].filter(Boolean)
),
first()
);
constructor(private walletsService: WalletsService, private transloco: TranslocoService) {}
getLabels() {
return {
claims: this.transloco.translate('sectionsLinks.links.claims', null, 'services'),
payments: this.transloco.translate('sectionsLinks.links.payments', null, 'services'),
wallets: this.transloco.translate('sectionsLinks.links.wallets', null, 'services'),
};
}
constructor(
private walletsService: WalletsService,
private transloco: TranslocoService,
private roleAccessService: RoleAccessService
) {}
}

View File

@ -1,11 +0,0 @@
import { SectionLink } from '../model';
export const createLinks = (
{ claims, payments, wallets }: Record<'claims' | 'payments' | 'wallets', string>,
hasWallets: boolean
): SectionLink[] =>
[
{ label: payments, path: `/payment-section` },
hasWallets && { label: wallets, path: '/wallet-section' },
{ label: claims, path: '/claim-section' },
].filter(Boolean);

View File

@ -1 +0,0 @@
export * from './create-links';

View File

@ -0,0 +1,8 @@
import { environment as prodEnvironment } from './environment.prod';
import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = {
...prodEnvironment,
production: false,
};

View File

@ -1,4 +1,8 @@
import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const environment = {
export const environment: Environment = {
production: true,
appConfigPath: '/appConfig.json',
authConfigPath: '/authConfig.json',
};

View File

@ -0,0 +1,9 @@
import { environment as devEnvironment } from './environment.dev';
import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = {
...devEnvironment,
appConfigPath: '/appConfig.stage.json',
authConfigPath: '/authConfig.stage.json',
};

View File

@ -1,11 +1,12 @@
import { environment as devEnvironment } from './environment.dev';
import { Environment } from './types/environment';
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
// eslint-disable-next-line @typescript-eslint/naming-convention
export const environment = {
production: false,
};
export const environment: Environment = devEnvironment;
/*
* For easier debugging in development mode, you can import the following file

View File

@ -0,0 +1,5 @@
export interface Environment {
production: boolean;
appConfigPath: string;
authConfigPath: string;
}

View File

@ -29,7 +29,8 @@
"@dsh/app/sections/tokens": ["src/app/sections/tokens.ts"],
"@dsh/type-utils": ["src/type-utils/index.ts"],
"@dsh/utils": ["src/utils/index.ts"],
"@dsh/operators": ["src/app/custom-operators/index.ts"]
"@dsh/operators": ["src/app/custom-operators/index.ts"],
"@dsh/app/*": ["src/app/*"]
}
},
"angularCompilerOptions": {