Гибкий способ адаптации UX/UI в приложении Angular в соответствии с разрешениями пользователя.

Во многих проектах существуют процессы аутентификации (в большей или меньшей степени). Было написано множество «лучших практик» по всем известным технологиям и так далее, и тому подобное.

Но что происходит после того, как пользователь выполнил вход? Ведь он далеко не все может сделать. Как определить, что он может видеть, а что нет. На какие кнопки он имеет право нажимать, что изменять, создавать и удалять.

В этой статье я хочу рассмотреть подход, используемый для решения этих проблем в веб-приложении.

Начнем с того, что настоящая/эффективная авторизация может происходить только на сервере. На front-end мы можем просто улучшить UI и UX. Мы можем скрыть кнопки, на которые пользователь не имеет права нажимать, или не дать ему попасть на страницы, или показать сообщение о том, что у него нет прав на выполнение определенного действия.

И здесь возникает вопрос, как сделать это максимально корректно? Давайте начнем с определения проблемы.

Мы создали приложение Todo App и в нем есть разные типы пользователей:

  • USER — может видеть и обновлять все задачи (ставить/снимать галочки), но не может удалять, создавать и видеть страницу статистики.

  • ADMIN — может видеть все задачи и создавать новые, но не может видеть статистику.

  • SUPER_ADMIN — может видеть все задачи, создавать новые и удалять их, а также видеть статистику.

В этой ситуации мы можем использовать «роли». Однако ситуация может сильно измениться. Представьте себе пользователя ADMIN с правами на удаление задач. Или пользователя USER с правами на просмотр статистики. Простое решение — создать новые роли.

Но в больших приложениях, с массивной системой ролей пользователей, мы быстро запутаемся в огромном количестве ролей…

И здесь мы вспоминаем о разрешениях «прав пользователя». Для более удобного управления мы можем создавать группы из нескольких разрешений и прикреплять их к пользователю. Всегда есть возможность добавить конкретное разрешение конкретному пользователю.

Подобные решения можно встретить во многих крупных сервисах: AWS, Google Cloud, SalesForce и так далее. Также подобные решения уже реализованы во многих фреймворках, например, Django (Python).

Я же хочу привести пример реализации для приложений Angular. (На примере того же ToDo App).

Для начала определим все возможные разрешения.

  1. Делим на функции.У нас есть задачи и статистика.
  2. Определяем возможные действия с каждой из них.Задача: создание, чтение, обновление, удаление.Статистика: чтение (в нашем примере только просмотр).
  3. Создаем карту ролей и разрешений.
export const permissionsMap = {
    todos:{
        create:'*',
        read:'*',
        update:'*',
        delete:'*'
    },
    stats:'*'
}
Вход в полноэкранный режим Выход из полноэкранного режима

На мой взгляд, это лучший вариант, но в большинстве случаев сервер вернет что-то вроде этого:

export permissions = [
  'todos_create', 
  'todos_read', 
  'todos_update', 
  'todos_delete', 
  'stats',
]
Войти в полноэкранный режим Выйти из полноэкранного режима

Менее читабельно, но тоже неплохо.

Вот как выглядит наше приложение, когда пользователь уже аутентифицирован, но UI/UX еще не адаптирован в соответствии с правами пользователя:

Давайте посмотрим на полномочия пользователя:

export const USERpermissionsMap = {
    todos:{
        read:'*',
        update:'*',
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Пользователь не может видеть статистику, это означает, что он в принципе не может перемещаться по этой странице.

Для таких ситуаций в Angular существуют Guards, которые используются на уровне Routes (документация).

@Injectable({
    providedIn: 'root'
})
export class PermissionsGuardService implements CanActivate {
    constructor() {
    }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
        const userPerms = getPermissions();
        const required = route.data.permission;

        const isPermitted = checkPermissions(required, userPerms);

        if (!isPermitted) {
            alert('ROUTE GUARD SAYS: n You don't have permissions to see this page');
        }

        return isPermitted;
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание на объект в data.permissions = 'stats', это именно те разрешения, которые должны быть у пользователя, чтобы иметь доступ к этой странице.

На основании требуемых data.permissions и permissions пользователя PermissionsGuardService решит, разрешить или нет доступ к странице ‘/stats’.

getPermissions(); вспомогательная функция, которая возвращает объект с разрешениями пользователя.

export function getPermissions() {
    let userPerms;
    // In our example we using stor as a single source of truth. 
    // Full implementation can be found here https://github.com/danduh/ngx-to-do-permissions.

    const store: Store<any> = AppInjector.get(Store);
    store
        .pipe(
            select(userPermissionsState),
            take(1)
        )
        .subscribe((_p) => {
            userPerms = _p ? _p : {};
        });
    return userPerms;
}

export function checkPermissions(required: string, userPerms) {
    // 1) Separate feature and action
    const [feature, action] = required.split('_');

    // 2) Check if user have any type of access to the feature
    if (!userPerms.hasOwnProperty(feature)) {
        return false;
    }

    // 3) Check if user have permission for required action
    if (!userPerms[feature].hasOwnProperty(action)) {
        return false;
    }

    return true;
}
Войти в полноэкранный режим Выход из полноэкранного режима

Скрытие элементов

Теперь наш пользователь не может перейти на страницу статистики. Однако он все еще может создавать задачи и удалять их.

Чтобы пользователь не мог удалять задания, достаточно убрать красный символ (X) из строки задания.

Для этого мы создадим пользовательскую Структурную директиву.

@Directive({
    selector: '[appPermissions]'
})
export class PermissionsDirective {
    private _required: string;
    private _viewRef: EmbeddedViewRef<any> | null = null;
    private _templateRef: TemplateRef<any> | null = null;

    @Input()
    set appPermissions(permission: string) {
        this._required = permission;
        this._viewRef = null;
        this.init();
    }

    constructor(private templateRef: TemplateRef<any>,
                private viewContainerRef: ViewContainerRef) {
        this._templateRef = templateRef;
    }

    init() {
        const userPerms = getPermmisions();
        const isPermitted = checkPermissions(this._required, userPerms);

        if (isPermitted) {
            this._viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
        } else {
            console.log('PERMISSIONS DIRECTIVE says n You don't have permissions to see it');
        }
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
<!-- 
  Similar to other Structural Directives in angular 
  (*ngIF, *ngFor..) we have '*' in directive name
  'todos_delete' -> is an Input() for directive 
  as a required permission to see this btn.
-->
<button
    *appPermissions="'todos_delete'"
    class="delete-button"
    (click)="removeSingleTodo()"> X
</button>
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь пользователь не видит кнопку DELETE, но может добавлять новые задачи.

Примите состояние элемента:

Если мы уберем поле ввода, весь внешний вид нашего приложения испортится. Правильным решением в этой ситуации будет отключить поле ввода.

Давайте попробуем сделать это с помощью трубы.

<!-- 
  as a value `permissions` pipe will get required permission
  `permissions` pipe return true or false, that's why we have !('todos_create' | permissions)
  to set disable=true if pipe returns false
 -->
<input class="centered-block"
       [disabled]="!('todos_create' | permissions)"
       placeholder="What needs to be done?" autofocus/>
Вход в полноэкранный режим Выход из полноэкранного режима
@Pipe({
    name: 'permissions'
})
export class PermissionsPipe implements PipeTransform {
    constructor() {
    }

    transform(required: any, args?: any): any {
        const userPerms = getPermissions();
        const isPermitted = checkPermissions(required, userPerms);

        if (isPermitted) {
            return true;
        } else {
            console.log('[PERMISSIONS PIPE] You don't have permissions');
            return false;
        }
    }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь пользователь может только видеть задачи и изменять их (отмечать/отменять отметки). Тем не менее, у нас все еще есть кнопка «Очистить выполненное», полностью функциональная и доступная для пользователя.

Декораторы:

Предположим, что у нас есть следующие требования от нашего менеджера продукта:

  1. Кнопка «Очистить завершенное» должна быть видна всем и всегда.

  2. Она также должна быть кликабельной.

  3. В случае, если USER без соответствующих прав нажимает на кнопку, должно появиться сообщение об ошибке.

Конструктивная директива нам не поможет, как и трубы.

Также не очень удобно регистрировать проверку прав доступа в функциях компонента. Все, что нам нужно, это проверка прав доступа между нажатием кнопки и выполнением связанной функции.

На мой взгляд, здесь стоит использовать декораторы.

@Component({
    selector: 'app-actions',
    templateUrl: './actions.component.html',
    styleUrls: ['./actions.component.css']
})
export class ActionsComponent implements OnInit {
    @Output() deleteCompleted = new EventEmitter();
    constructor() {
    }

    // Our custom decorator, as a param we pass required permission
    @Permissions('todos_delete')
    public deleteCompleted() {
        this.deleteCompleted.emit();
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима
export function Permissions(required) {
    return (classProto, propertyKey, descriptor) => {

        const originalFunction = descriptor.value;
        descriptor.value = function (...args: any[]) {
            const userPerms = getPermissions();
            const isPermitted = checkPermissions(required, userPerms);
            if (isPermitted) {
                originalFunction.apply(this, args);
            } else {
                // Should throw/log error, but for better visibility will use alert()
                alert('PERMISSIONS DECORATOR says n you have no permissions');
            }
        };

        return descriptor;
    };
}
Вход в полноэкранный режим Выход из полноэкранного режима

И наш конечный результат:

Заключение

Возможно, это не самые лучшие решения. Но этот подход позволяет нам легко и динамично адаптировать наш UX в соответствии с разрешениями, которые есть у пользователя.

Ссылка на репо

Спасибо Петру Пшеничному

Оцените статью
devanswers.ru
Добавить комментарий