Skip to content

Commit

Permalink
feat(admin-ui): Add support for permissions on custom fields
Browse files Browse the repository at this point in the history
Relates to #2671
  • Loading branch information
michaelbromley committed Feb 15, 2024
1 parent 1c9f8f9 commit 94e0c42
Show file tree
Hide file tree
Showing 15 changed files with 126 additions and 106 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
LanguageCode,
NotificationService,
Permission,
PermissionsService,
ProductOptionFragment,
ProductOptionGroupFragment,
ServerConfigService,
Expand Down Expand Up @@ -48,12 +49,13 @@ export class ProductOptionsEditorComponent extends BaseDetailComponent<ProductWi
protected router: Router,
protected serverConfigService: ServerConfigService,
protected dataService: DataService,
protected permissionsService: PermissionsService,
private productDetailService: ProductDetailService,
private formBuilder: UntypedFormBuilder,
private changeDetector: ChangeDetectorRef,
private notificationService: NotificationService,
) {
super(route, router, serverConfigService, dataService);
super(route, router, serverConfigService, dataService, permissionsService);
this.optionGroupCustomFields = this.getCustomFieldConfig('ProductOptionGroup');
this.optionCustomFields = this.getCustomFieldConfig('ProductOption');
}
Expand Down
17 changes: 15 additions & 2 deletions packages/admin-ui/src/lib/core/src/common/base-detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { distinctUntilChanged, filter, map, shareReplay, switchMap, takeUntil, t
import { DataService } from '../data/providers/data.service';
import { ServerConfigService } from '../data/server-config';
import { BreadcrumbValue } from '../providers/breadcrumb/breadcrumb.service';
import { PermissionsService } from '../providers/permissions/permissions.service';

import { DeactivateAware } from './deactivate-aware';
import { CustomFieldConfig, CustomFields, LanguageCode } from './generated-types';
Expand Down Expand Up @@ -70,6 +71,7 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
protected router: Router,
protected serverConfigService: ServerConfigService,
protected dataService: DataService,
protected permissionsService: PermissionsService,
) {}

init() {
Expand Down Expand Up @@ -149,7 +151,12 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
}

protected getCustomFieldConfig(key: Exclude<keyof CustomFields, '__typename'>): CustomFieldConfig[] {
return this.serverConfigService.getCustomFieldsFor(key);
return this.serverConfigService.getCustomFieldsFor(key).filter(f => {
if (f.requiresPermission?.length) {
return this.permissionsService.userHasPermissions(f.requiresPermission);
}
return true;
});
}

protected setQueryParam(key: string, value: any) {
Expand Down Expand Up @@ -184,7 +191,13 @@ export abstract class TypedBaseDetailComponent<
protected entity: ResultOf<T>[Field];

protected constructor() {
super(inject(ActivatedRoute), inject(Router), inject(ServerConfigService), inject(DataService));
super(
inject(ActivatedRoute),
inject(Router),
inject(ServerConfigService),
inject(DataService),
inject(PermissionsService),
);
}

override init() {
Expand Down
10 changes: 8 additions & 2 deletions packages/admin-ui/src/lib/core/src/common/base-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { QueryResult } from '../data/query-result';
import { ServerConfigService } from '../data/server-config';
import { DataTableFilterCollection } from '../providers/data-table/data-table-filter-collection';
import { DataTableSortCollection } from '../providers/data-table/data-table-sort-collection';
import { PermissionsService } from '../providers/permissions/permissions.service';
import { CustomFieldConfig, CustomFields, LanguageCode } from './generated-types';
import { SelectionManager } from './utilities/selection-manager';

Expand Down Expand Up @@ -178,7 +179,6 @@ export class BaseListComponent<ResultType, ItemType, VariableType extends Record
valueOrOptions?: any,
maybeOptions?: { replaceUrl?: boolean; queryParamsHandling?: QueryParamsHandling },
) {
const paramsObject = typeof keyOrHash === 'string' ? { [keyOrHash]: valueOrOptions } : keyOrHash;
const options = (typeof keyOrHash === 'string' ? maybeOptions : valueOrOptions) ?? {};
this.router.navigate(['./'], {
queryParams: typeof keyOrHash === 'string' ? { [keyOrHash]: valueOrOptions } : keyOrHash,
Expand Down Expand Up @@ -211,6 +211,7 @@ export class TypedBaseListComponent<
protected dataService = inject(DataService);
protected router = inject(Router);
protected serverConfigService = inject(ServerConfigService);
protected permissionsService = inject(PermissionsService);
private refreshStreams: Array<Observable<any>> = [];
private collections: Array<DataTableFilterCollection | DataTableSortCollection<any>> = [];
constructor() {
Expand Down Expand Up @@ -263,6 +264,11 @@ export class TypedBaseListComponent<
}

getCustomFieldConfig(key: Exclude<keyof CustomFields, '__typename'> | string): CustomFieldConfig[] {
return this.serverConfigService.getCustomFieldsFor(key);
return this.serverConfigService.getCustomFieldsFor(key).filter(f => {
if (f.requiresPermission?.length) {
return this.permissionsService.userHasPermissions(f.requiresPermission);
}
return true;
});
}
}
10 changes: 10 additions & 0 deletions packages/admin-ui/src/lib/core/src/common/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export type BooleanCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand Down Expand Up @@ -1317,6 +1318,7 @@ export type CustomField = {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand Down Expand Up @@ -1515,6 +1517,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
step?: Maybe<Scalars['Int']['output']>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
Expand Down Expand Up @@ -1820,6 +1823,7 @@ export type FloatCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
step?: Maybe<Scalars['Float']['output']>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
Expand Down Expand Up @@ -2025,6 +2029,7 @@ export type IntCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
step?: Maybe<Scalars['Int']['output']>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
Expand Down Expand Up @@ -2489,6 +2494,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
nullable?: Maybe<Scalars['Boolean']['output']>;
pattern?: Maybe<Scalars['String']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand All @@ -2502,6 +2508,7 @@ export type LocaleTextCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand Down Expand Up @@ -5489,6 +5496,7 @@ export type RelationCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
scalarFields: Array<Scalars['String']['output']>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
Expand Down Expand Up @@ -6000,6 +6008,7 @@ export type StringCustomFieldConfig = CustomField & {
options?: Maybe<Array<StringFieldOption>>;
pattern?: Maybe<Scalars['String']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand Down Expand Up @@ -6241,6 +6250,7 @@ export type TextCustomFieldConfig = CustomField & {
name: Scalars['String']['output'];
nullable?: Maybe<Scalars['Boolean']['output']>;
readonly?: Maybe<Scalars['Boolean']['output']>;
requiresPermission?: Maybe<Array<Permission>>;
type: Scalars['String']['output'];
ui?: Maybe<Scalars['JSON']['output']>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ export const CUSTOM_FIELD_CONFIG_FRAGMENT = gql`
}
readonly
nullable
requiresPermission
ui
}
`;
Expand Down
1 change: 0 additions & 1 deletion packages/admin-ui/src/lib/core/src/data/server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
GetServerConfigQuery,
OrderProcessState,
PermissionDefinition,
ServerConfig,
} from '../common/generated-types';

import { GET_GLOBAL_SETTINGS, GET_SERVER_CONFIG } from './definitions/settings-definitions';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
import { Injectable } from '@angular/core';
import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
import {
BehaviorSubject,
combineLatest,
interval,
isObservable,
Observable,
of,
Subject,
switchMap,
} from 'rxjs';
import { filter, first, map, mapTo, startWith, take } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, interval, isObservable, Observable, Subject, switchMap } from 'rxjs';
import { map, startWith, take } from 'rxjs/operators';
import { Permission } from '../../common/generated-types';
import { DataService } from '../../data/providers/data.service';
import { PermissionsService } from '../permissions/permissions.service';

export interface AlertConfig<T = any> {
id: string;
Expand Down Expand Up @@ -86,9 +77,9 @@ export class AlertsService {
private alertsMap = new Map<string, Alert<any>>();
private configUpdated = new Subject<void>();

constructor(private dataService: DataService) {
constructor(private permissionsService: PermissionsService) {
const alerts$ = this.configUpdated.pipe(
mapTo([...this.alertsMap.values()]),
map(() => [...this.alertsMap.values()]),
startWith([...this.alertsMap.values()]),
);

Expand All @@ -103,26 +94,17 @@ export class AlertsService {
}

configureAlert<T>(config: AlertConfig<T>) {
this.hasSufficientPermissions(config.requiredPermissions)
.pipe(first())
.subscribe(hasSufficientPermissions => {
if (hasSufficientPermissions) {
this.alertsMap.set(config.id, new Alert(config));
this.configUpdated.next();
}
});
if (this.hasSufficientPermissions(config.requiredPermissions)) {
this.alertsMap.set(config.id, new Alert(config));
this.configUpdated.next();
}
}

hasSufficientPermissions(permissions?: Permission[]) {
if (!permissions || permissions.length === 0) {
return of(true);
return true;
}
return this.dataService.client.userStatus().stream$.pipe(
filter(({ userStatus }) => userStatus.isLoggedIn),
map(({ userStatus }) =>
permissions.some(permission => userStatus.permissions.includes(permission)),
),
);
return this.permissionsService.userHasPermissions(permissions);
}

refresh(id?: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AttemptLoginMutation, CurrentUserFragment } from '../../common/generate
import { DataService } from '../../data/providers/data.service';
import { ServerConfigService } from '../../data/server-config';
import { LocalStorageService } from '../local-storage/local-storage.service';
import { PermissionsService } from '../permissions/permissions.service';

/**
* This service handles logic relating to authentication of the current user.
Expand All @@ -19,6 +20,7 @@ export class AuthService {
private localStorageService: LocalStorageService,
private dataService: DataService,
private serverConfigService: ServerConfigService,
private permissionsService: PermissionsService,
) {}

/**
Expand All @@ -39,15 +41,16 @@ export class AuthService {
}),
switchMap(login => {
if (login.__typename === 'CurrentUser') {
const { id } = this.getActiveChannel(login.channels);
const activeChannel = this.getActiveChannel(login.channels);
this.permissionsService.setCurrentUserPermissions(activeChannel.permissions);
return this.dataService.administrator.getActiveAdministrator().single$.pipe(
switchMap(({ activeAdministrator }) => {
if (activeAdministrator) {
return this.dataService.client
.loginSuccess(
activeAdministrator.id,
`${activeAdministrator.firstName} ${activeAdministrator.lastName}`,
id,
activeChannel.id,
login.channels,
)
.pipe(map(() => login));
Expand Down Expand Up @@ -107,16 +110,16 @@ export class AuthService {
return of(false) as any;
}
this.setChannelToken(me.channels);

const { id } = this.getActiveChannel(me.channels);
const activeChannel = this.getActiveChannel(me.channels);
this.permissionsService.setCurrentUserPermissions(activeChannel.permissions);
return this.dataService.administrator.getActiveAdministrator().single$.pipe(
switchMap(({ activeAdministrator }) => {
if (activeAdministrator) {
return this.dataService.client
.loginSuccess(
activeAdministrator.id,
`${activeAdministrator.firstName} ${activeAdministrator.lastName}`,
id,
activeChannel.id,
me.channels,
)
.pipe(map(() => true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import { map, shareReplay, tap } from 'rxjs/operators';
import { UserStatusFragment } from '../../common/generated-types';
import { DataService } from '../../data/providers/data.service';
import { LocalStorageService } from '../local-storage/local-storage.service';
import { PermissionsService } from '../permissions/permissions.service';

@Injectable({
providedIn: 'root',
})
export class ChannelService {
defaultChannelIsActive$: Observable<boolean>;

constructor(private dataService: DataService, private localStorageService: LocalStorageService) {
constructor(
private dataService: DataService,
private localStorageService: LocalStorageService,
private permissionsService: PermissionsService,
) {
this.defaultChannelIsActive$ = this.dataService.client
.userStatus()
.mapStream(({ userStatus }) => {
Expand All @@ -30,6 +35,7 @@ export class ChannelService {
const activeChannel = userStatus.channels.find(c => c.id === channelId);
if (activeChannel) {
this.localStorageService.set('activeChannelToken', activeChannel.token);
this.permissionsService.setCurrentUserPermissions(activeChannel.permissions);
}
}),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { Permission } from '../../common/generated-types';

/**
* @description
* This service is used internally to power components & logic that are dependent on knowing the
* current user's permissions in the currently-active channel.
*/
@Injectable({
providedIn: 'root',
})
export class PermissionsService {
private currentUserPermissions: string[] = [];
private _currentUserPermissions$ = new BehaviorSubject<string[]>([]);
currentUserPermissions$ = this._currentUserPermissions$.asObservable();

/**
* @description
* This is called whenever:
* - the user logs in
* - the active channel changes
*
* Since active user validation occurs as part of the main auth guard, we can be assured
* that if the user is logged in, then this method will be called with the user's permissions
* before any other components are rendered lower down in the component tree.
*/
setCurrentUserPermissions(permissions: string[]) {
this.currentUserPermissions = permissions;
this._currentUserPermissions$.next(permissions);
}

userHasPermissions(requiredPermissions: Array<string | Permission>): boolean {
for (const perm of requiredPermissions) {
if (this.currentUserPermissions.includes(perm)) {
return true;
}
}
return false;
}
}
Loading

0 comments on commit 94e0c42

Please sign in to comment.