Hello,
I have been trying to wrap my head around CASL and its react package and I cannot really seem to get comfortable with it. I will explain what I am hoping to do with CASL in the hopes of getting an answer I can understand! I apologise if this has been asked before.
Essentially I have an application will use react and redux, with redux store holding the logged in user (see the reducer schema below). Role with either be blank, "researcher" or "admin"
{
isLoggedIn: null,
user: null,
role: '',
errorMsg: ''
}
Admins and researchers should be able to visit certain routes. (i.e. admin can visit '/add/proposal' but the researchers cannot, and the researchers can visit ('proposals/all') but the admin cannot etc.)
Where I get lost when following examples is as follows:
import { AbilityBuilder } from 'casl'
/**
* Defines how to detect object's type: https://stalniy.github.io/casl/abilities/2017/07/20/define-abilities.html
*/
function subjectName(item) {
if (!item || typeof item === 'string') {
return item
}
return item.__type
}
export default AbilityBuilder.define({ subjectName }, can => {
can(['read', 'create'], 'Todo')
can(['update', 'delete'], 'Todo', { assignee: 'me' })
})
Basically the final lines lose me. I understand that Todo is the object for which the rule is applicable, but how would that apply for routes etc? What object goes in its place? Should I just place "Route" in place of Todo? What is the purpose of {assignee: 'me' } also?
I am aware you have a previous react redux question but again it doesn't help me get it. Again, I apologise.
function defineRulesFor(auth) {
const { can, rules } = AbilityBuilder.extract()
can('delete', 'Post', { userId: auth.userId });
return rules
}
The above code was taken from a previously answered redux question. Where 'Post' is the part I don't understand.
A TLDR: I would like to know what to place where 'Post' and 'Todo' are above when creating the rules for certain user roles (admin and researcher) with the hope of using it to hide routes from one while showing for another. Essentially what is this object I am applying the rules to.
Hi
Please read this thread https://github.com/stalniy/casl/issues/135 if it doesn鈥檛 help I鈥檒l try to answer on this week
About routes I answered on medium:
https://medium.com/@brankompas/maybe-something-like-that-where-some-of-the-routes-would-be-available-for-a-specific-role-d26c9be99392
I guess it鈥檚 time to create FAQ section :)
Hi, thanks for the quick reply!
With regards to your medium article, I see you've given your example using Vue and also state not to check routes on the UI.
So what would you recommend for reach (using react router) ?
Thanks.
The same approaches are applicable to React. But I don鈥檛 know whether React Router allows to specifyp metadata for specific route...
I have since implemented the following to solve the issue, if theres an improvement please let me know.
For the ability.js file I have the following:
/* eslint-disable no-underscore-dangle */
import { Ability, AbilityBuilder } from '@casl/ability';
import store from '../store/index';
// Defines how to detect object's type
function subjectName(item) {
if (!item || typeof item === 'string') {
return item;
}
return item.__type;
}
const ability = new Ability([], { subjectName });
let currentAuth;
store.subscribe(() => {
const prevAuth = currentAuth;
currentAuth = store.getState().currentUserReducer;
if (prevAuth !== currentAuth) {
ability.update(defineRulesFor(currentAuth));
}
});
function defineRulesFor(auth) {
const { can, rules, cannot } = AbilityBuilder.extract();
if (auth.role === 'researcher') {
can('view', 'Researcher', { userId: auth.user.id });
cannot('view', 'Admin', { userId: auth.user.id });
}
if (auth.role === 'admin') {
can('view', 'Admin', { userId: auth.user.id });
}
return rules;
}
export default ability;
Majority of this code taken from a previous redux question @stalniy answered.
Place your current user object from redux store where mine has currentUserReducer.
Within my routes.jsx file I then implement "hiding" routes by doing the following:
const Routes = () => (
<Switch>
<Route exact path="/" component={GridCards} />
<Route path="/profile" component={Profile} />
<Route
exact
path="/admin/proposals"
render={props => (
<Can I="view" a="Admin">
{() => <AppProposalPage {...props} />}
</Can>
)}
/>
To ensure the admin can't see something the researcher can, simply change the <Can/> component to a="Researcher"
Should the author of this package have any improvements I can edit/update my reply. Hope this helps someone!
I guess Admin and Researcher are your roles in the system, right? If so, then the logic is incorrect because subject is not a role but a subject/resource/entity which user can manipulate. So, from the description it鈥檚 clear that you have entity Proposal, and rules needs to be defined for Proposal. Think what user can do in your system instead of thinking which role he has
For example:
const Routes = () => (
<Switch>
<Route exact path="/" component={GridCards} />
<Route path="/profile" component={Profile} />
<Route
exact
path="/admin/proposals"
render={props => (
<Can I="create" a="Proposal">
{() => <AppProposalPage {...props} />}
</Can>
)}
/>
The ability will look like this:
function defineRulesFor(auth) {
const { can, rules, cannot } = AbilityBuilder.extract();
if (auth.role === 'researcher') {
can('read', 'Proposal');
} else if (auth.role === 'admin') {
can('create', 'Proposal');
}
return rules;
}
P.s.: if you don鈥檛 pass objects into subject in <Can>, you can omit part with subjectName, the default implementation will care about proper mapping
Frankly speaking it鈥檚 a bit strange that admin cannot read proposals. Maybe he can read at least proposals which were created by him?
@dwalsh01 any updates? If all good, I will close the issue
Close due to inactivity
@stalniy sorted it out thank you very your help.
It's very hard to use <Can /> component if you need to check permissions on didMount/didUpdate. So it makes sense to do a HOC similar to redux connect()
Here's my solution (it doesn't require @casl/react). Might be useful :)
import { connect } from 'react-redux';
import get from 'lodash/get';
import { Ability, AbilityBuilder } from '@casl/ability';
const userAbilities = new Ability([]);
let cachedUserCan;
let cachedUser;
const defineAbilitiesFor = user => {
const { rules, can } = AbilityBuilder.extract();
.......
return rules;
};
// NOTE:
// connect() will trigger re-render only if props were changed.
// That's why we use cachedUserCan
const mapStateToProps = ({ currentUser }) => {
const user = get(currentUser, 'info');
if (user !== cachedUser) {
cachedUserCan = userAbilities.can.bind(userAbilities);
cachedUser = user;
if (user) {
userAbilities.update(defineAbilitiesFor(user));
} else {
userAbilities.update([]);
}
}
return {
userCan: cachedUserCan
};
};
const withAbility = Component => connect(mapStateToProps)(Component);
export default withAbility;
And example usecase: tabs list with optional tabs
class DashboardTabs extends Component {
static propTypes = {
location: PropTypes.shape({
hash: PropTypes.string.isRequired
}).isRequired,
userCan: PropTypes.func.isRequired
};
state = {
activeTabId: null,
activeTabData: null,
isReady: false
};
requestedSubscription = null;
componentDidMount() {
const hashTabId = this.getTabIdFromHash();
const activeTabId = hashTabId || Object.keys(this.getTabs())[0];
this.handleTabChange(activeTabId);
}
componentDidUpdate(prevProps) {
if (prevProps.location.hash !== this.props.location.hash) {
const newTabId = this.getTabIdFromHash();
if (newTabId) this.handleTabChange(newTabId);
}
}
componentWillUnmount() {
cancelPendingRequests(this.requestedSubscription);
}
getTabs = () => ({
holdings: {
label: 'Holdings',
component: HoldingsList,
fetch: getPortfolioHoldings
},
...(this.props.userCan('manage', 'security') && {
following: {
label: 'Following',
component: FollowingList,
fetch: getFollowingSecurities
}
}),
pending: {
label: 'Pending',
component: PendingList,
fetch: getPortfolioPendings
},
activity: {
label: 'Activity',
component: ActivityList,
fetch: getPortfolioActivities
}
});
getTabIdFromHash = () => {
const { hash } = this.props.location;
const tabId = hash ? hash.slice(1) : null;
return Object.keys(this.getTabs()).includes(tabId) ? tabId : null;
};
handleTabChange = tabId => {
this.setState({ activeTabId: tabId, activeTabData: null });
cancelPendingRequests(this.requestedSubscription);
this.requestedSubscription = this.getTabs()
// eslint-disable-next-line no-unexpected-multiline
[tabId].fetch()
.subscribe(({ response: { data } }) => {
this.setState({ activeTabData: data, isReady: true });
});
};
renderContent() {
const { activeTabId, activeTabData } = this.state;
const tabsById = this.getTabs();
const ContentComponent = tabsById[activeTabId].component;
return (
<Container>
<NavContainer>
{Object.keys(tabsById).map(tabId => (
<NavButton
href={'#' + tabId}
key={tabId}
selected={activeTabId === tabId}
>
{tabsById[tabId].label}
</NavButton>
))}
</NavContainer>
<ContentComponent data={activeTabData} />
</Container>
);
}
render() {
return this.state.isReady ? this.renderContent() : <Skeleton />;
}
}
export default withRouter(withAbility(DashboardTabs));