Skip to main content

Reasons. Причины отказа в доступе

Каждый permission возвращает объект:

type Permission = {
/**
* Разрешен ли доступ
*/
isAllowed: boolean;
/**
* Причина отказа в доступе
*/
reason?: PermissionDenialReason;
hasReason: (reason: string) => boolean;
};

reason указывает причину отказа в доступе и благодаря этому позволяет:

  • Улучшить UX: при работе с доступом есть информация о причине отказа, на основе которой пользователю можно показать подсказки
  • Улучшить DX: при отладке можно получить информацию о причине отказа
  • Улучшить observability: при нежелательных ошибках в доступе есть информация о причине

Пример использования

Требования

Пользователь не может добавить книгу на свою полку, если:

  • Аккаунт не оплачен
  • Превышено доступное количество книг, которое можно добавить на полку

Если у пользователя не оплачен аккаунт, то при нажатии на кнопку "Добавить на полку" должна открыться модалка с предложением об оплате. Если пользователь достиг предел добавленных книг на полку, то необходимо отобразить уведомление "Достигнуто максимальное количество книг на полке".

Решение

modules/permissions/domain/stores/PermissionsStore/policies/BooksPolicy

// @astral/permissions в реальном коде должен реэкспортироваться через shared
import { PolicyManagerStore, Policy } from '@astral/permissions';

export class BooksPolicyStore {
private readonly policy: PermissionsPolicy;

constructor(
policyManager: PolicyManagerStore,
private readonly billingRepo: BillingRepository,
private readonly userRepo: UserRepository,
) {
makeAutoObservable(this, {}, { autoBind: true });

this.policy = policyManager.createPolicy({
name: 'books',
prepareData: async () => {
await Promise.all([
this.userRepo.getRolesQuery().async(),
this.billingRepo.getBillingInfoQuery().async(),
]);
},
});
}

/**
* Возможность добавить на полку книгу
*/
public get addingToShelf() {
return this.policy.createPermission((allow, deny) => {
if (this.userRepo.getRolesQuery().data?.isAdmin) {
return allow();
}

const billingInfo = this.billingRepo.getBillingInfoQuery()?.data;

if (!billingInfo?.paid) {
return deny(PermissionDenialReason.NoPayAccount);
}

if (
billingInfo.info.shelf.currentCount >=
billingInfo.info.shelf.allowedCount
) {
return deny(PermissionDenialReason.ExceedShelfCount);
}

allow();
});
}
}

modules/books/features/BookCard/UIStore

// В реальном коде для импорта из другого модуля необходимо использовать external файл
import {
PermissionDenialReason,
PermissionsStore,
permissionsStore,
} from '@example/modules/permissions';

export class UIStore {
public isOpenPayAccount = false;

constructor(
private readonly bookId: string,
private readonly permissions: PermissionsStore,
private readonly notifyService: Notify,
) {
makeAutoObservable(this, {}, { autoBind: true });
}

public addToShelf = () => {
if (this.permissions.books.addingToShelf.isAllowed) {
this.notifyService.info(`Книга ${this.bookId} добавлена на полку`);

return;
}

if (this.permissions.books.addingToShelf.hasReason(PermissionDenialReason.NoPay)) {
this.openPaymentAccount();

return;
}

if (
this.permissions.books.addingToShelf.hasReason(PermissionDenialReason.ExceedReadingCount)
) {
this.notifyService.error(
'Достигнуто максимальное количество книг на полке',
);

return;
}

this.notifyService.error(
'Добавить книгу на полку нельзя. Попробуйте перезагрузить страницу',
);
};

public openPayAccount = () => {
this.isOpenPayAccount = true;
};

public closePayAccount = () => {
this.isOpenPayAccount = false;
};
}

export const createUIStore = (bookId: string) =>
new UIStore(bookId, permissionsStore, notify);

modules/books/features/BookCard/BookCard.tsx

type Props = {
id: string;
};

export const BookCard = observer(({ id }: Props) => {
const [{
addToShelf,
isOpenPayAccount,
closePayAccount
}] = useState(() => createUIStore(id));

return (
<>
<Container>
<BookInfo />
<Button onClick={addToShelf}>Добавить на полку</Button>
</Container>
<PayAccountModal
isOpen={isOpenPayAccount}
onClose={closePayAccount}
/>
</>
);
});

Все reasons хранятся в одном enum

Одни и те же причины отказа переиспользуются между разными permissions и policies.

Все reasons необходимо хранить в одном enum для того, чтобы не создавать дополнительные сложности декомпозиции при росте причин.

Reasons должны располагаться в modules/permissions/domain/enums.ts:

export enum PermissionDenialReason {
/**
* Не является администратором
* **/
NoAdmin = 'no-admin',
/**
* Аккаунт не оплачен
* **/
NoPayAccount = 'no-pay-account',
}

Пакет @astral/permissions содержит дополнительные системные причины отказа, которые могут произойти из-за ошибок в коде:

export enum SystemDenialReason {
/**
* При расчете доступа произошла ошибка
* **/
InternalError = 'internal-error',
/**
* Недостаточно данных для формирования доступа
* **/
MissingData = 'missing-data',
}

Для централизованного хранения reasons, необходимо объединить SystemDenialReason и reasons нашего модуля:

import { SystemDenialReason } from '@astral/permissions';

export enum PermissionsDenialReason {
/**
* При расчете доступа произошла ошибка
* **/
InternalError = SystemDenialReason.InternalError,
/**
* Недостаточно данных для формирования доступа
* **/
MissingData = SystemDenialReason.MissingData,
/**
* Пользователь не является админом
* **/
NoAdmin = 'no-admin',
}

Соглашения

  • Для каждого reason должен быть оставлен комментарий в виде jsdoc о предназначении данного reason
  • Значения reasons должны быть String в формате kebab-case