So, you have been working on this new and fancy web application. Be it a recipe app, a document manager, or even your private cloud, you‘ve now reached the point of working with users and permissions. Take the document manager as an example: you don’t just want admins; maybe you want to invite guests with read-only access or people who can edit but not delete your files. How do you handle that logic in the front end without cluttering your code with too many complicated conditions and checks?
In this article, we will go over an example implementation on how you could handle these kinds of situation in an elegant and clean manner. Take it with a grain of salt – your needs might differ, but I hope that you can gain some ideas from it.
Let‘s assume that you already built the back end, added a table for all users in your database, and maybe provided a dedicated column or property for roles. The implementation details are totally up to you (depending on your stack and preference). For the sake of this demo, let’s use the following roles:
- Admin: can do anything, like creating, deleting, and editing own or foreign documents.
- Editor: can create, view, and edit files but not delete them.
- Guest: can view files, simple as that.
Like most modern web applications out there, your app might use a RESTful API to communicate with the back end, so let’s use this scenario for the demo. Even if you go with something different like GraphQL or server-side rendering, you can still apply the same pattern we are going to look at.
The key is to return the role (or permission, if you prefer that name) of the currently logged-in user when fetching some data.
{
id: 1,
title: "My First Document",
authorId: 742,
accessLevel: "ADMIN",
content: {...}
}
Here, we fetch a document with some properties, including a property called accessLevel
for the user’s role. That’s how we know what the logged-in user is allowed or not allowed to do. Our next job is to add some logic in the front end to ensure that guests don‘t see things they’re not supposed to, and vice-versa.
Ideally, you don’t only rely on the frontend to check permissions. Someone experienced with web technologies could still send a request without UI to the server with the intent to manipulate data, hence your backend should be checking things as well.
By the way, this pattern is framework agnostic; it doesn’t matter if you work with React, Vue, or even some wild Vanilla JavaScript.
Defining constants
The very first (optional, but highly recommended) step is to create some constants. These will be simple objects that contain all actions, roles, and other important parts that the app might consist of. I like to put them into a dedicated file, maybe call it constants.js
:
const actions = {
MODIFY_FILE: "MODIFY_FILE",
VIEW_FILE: "VIEW_FILE",
DELETE_FILE: "DELETE_FILE",
CREATE_FILE: "CREATE_FILE"
};
const roles = {
ADMIN: "ADMIN",
EDITOR: "EDITOR",
GUEST: "GUEST"
};
export { actions, roles };
If you have the advantage of using TypeScript, you can use enums to get a slightly cleaner syntax.
Creating a collection of constants for your actions and roles has some advantages:
- One single source of truth. Instead of looking through your entire codebase, you simply open
constants.js
to see what’s possible inside your app. This approach is also very extensible, say when you add or remove actions. - No typing errors. Instead of hand-typing a role or action each time, making it prone to typos and unpleasant debugging sessions, you import the object and, thanks to your favorite editor’s magic, get suggestions and auto-completion for free. If you still mistype a name, ESLint or some other tool will most likely yell at you until you fix it.
- Documentation. Are you working in a team? New team members will appreciate the simplicity of not needing to go through tons of files to understand what permissions or actions exist. It can also be easily documented with JSDoc.
Using these constants is pretty straight-forward; import and use them like so:
import { actions } from "./constants.js";
console.log(actions.CREATE_FILE);
Defining permissions
Off to the exciting part: modeling a data structure to map our actions to roles. There are many ways to solve this problem, but I like the following one the most. Let’s create a new file, call it permissions.js
, and put some code inside:
import { actions, roles } from "./constants.js";
const mappings = new Map();
mappings.set(actions.MODIFY_FILE, [roles.ADMIN, roles.EDITOR]);
mappings.set(actions.VIEW_FILE, [roles.ADMIN, roles.EDITOR, roles.GUEST]);
mappings.set(actions.DELETE_FILE, [roles.ADMIN]);
mappings.set(actions.CREATE_FILE, [roles.ADMIN, roles.EDITOR]);
Let’s go through this, step-by-step:
- First, we need to import our constants.
- We then create a new JavaScript Map, called
mappings
. We could’ve gone with any other data structure, like objects, arrays, you name it. I like to use Maps, since they offer some handy methods, like.has()
,.get()
, etc. - Next, we add (or rather set) a new entry for each action our app has. The action serves as the key, by which we then get the roles required to execute said action. As for the value, we define an array of necessary roles.
This approach might seem strange at first (it did to me), but I learned to appreciate it over time. The benefits are evident, especially in larger applications with tons of actions and roles:
- Again, only one source of truth. Do you need to know what roles are required to edit a file? No problem, head over to
permissions.js
and look for the entry. - Modifying business logic is surprisingly simple. Say your product manager decides that, from tomorrow on, editors are allowed to delete files; simply add their role to the
DELETE_FILE
entry and call it a day. The same goes for adding new roles: add more entries to the mappings variable, and you’re good to go. - Testable. You can use snapshot tests to make sure that nothing changes unexpectedly inside these mappings. It’s also clearer during code reviews.
The above example is rather simple and could be extended to cover more complicated cases. If you have different file types with different role access, for example. More on that at the end of this article.
Checking permissions in the UI
We defined all of our actions and roles and we created a map that explains who is allowed to do what. It’s time to implement a function for us to use in our UI to check for those roles.
When creating such new behavior, I always like to start with how the API should look. Afterwards, I implement the actual logic behind that API.
Say we have a React Component that renders a dropdown menu:
function Dropdown() {
return (
<ul>
<li><button type="button">Refresh</button><li>
<li><button type="button">Rename</button><li>
<li><button type="button">Duplicate</button><li>
<li><button type="button">Delete</button><li>
</ul>
);
}
Obviously, we don’t want guests to see nor click the option “Delete” or “Rename,” but we want them to see “Refresh.” On the other hand, editors should see all but “Delete.” I imagine some API like this:
hasPermission(file, actions.DELETE_FILE);
The first argument is the file itself, as fetched by our REST API. It should contain the accessLevel
property from earlier, which can either be ADMIN
, EDITOR
, or GUEST
. Since the same user might have different permissions in different files, we always need to provide that argument.
As for the second argument, we pass an action, like deleting the file. The function should then return a boolean true
if the currently logged-in user has permissions for that action, or false
if not.
import hasPermission from "./permissions.js";
import { actions } from "./constants.js";
function Dropdown() {
return (
<ul>
{hasPermission(file, actions.VIEW_FILE) && (
<li><button type="button">Refresh</button></li>
)}
{hasPermission(file, actions.MODIFY_FILE) && (
<li><button type="button">Rename</button></li>
)}
{hasPermission(file, actions.CREATE_FILE) && (
<li><button type="button">Duplicate</button></li>
)}
{hasPermission(file, actions.DELETE_FILE) && (
<li><button type="button">Delete</button></li>
)}
</ul>
);
}
You might want to find a less verbose function name or maybe even a different way to implement the entire logic (currying comes to mind), but for me, this has done a pretty good job, even in applications with super complex permissions. Sure, the JSX looks more cluttered, but that’s a small price to pay. Having this pattern consistently used across the entire app makes permissions a lot cleaner and more intuitive to understand.
In case you are still not convinced, let’s see how it would look without the hasPermission
helper:
return (
<ul>
{['ADMIN', 'EDITOR', 'GUEST'].includes(file.accessLevel) && (
<li><button type="button">Refresh</button></li>
)}
{['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
<li><button type="button">Rename</button></li>
)}
{['ADMIN', 'EDITOR'].includes(file.accessLevel) && (
<li><button type="button">Duplicate</button></li>
)}
{file.accessLevel == "ADMIN" && (
<li><button type="button">Delete</button></li>
)}
</ul>
);
You might say that this doesn’t look too bad, but think about what happens if more logic is added, like license checks or more granular permissions. Things tend to get out of hand quickly in our profession.
Are you wondering why we need the first permission check when everybody may see the “Refresh” button anyways? I like to have it there because you never know what might change in the future. A new role might get introduced that may not even see the button. In that case, you only have to update your permissions.js
and get to leave the component alone, resulting in a cleaner Git commit and fewer chances to mess up.
Implementing the permission checker
Finally, it’s time to implement the function that glues it all together: actions, roles, and the UI. The implementation is pretty straightforward:
import mappings from "./permissions.js";
function hasPermission(file, action) {
if (!file?.accessLevel) {
return false;
}
if (mappings.has(action)) {
return mappings.get(action).includes(file.accessLevel);
}
return false;
}
export default hasPermission;
export { actions, roles };
You can put the above code into a separate file or even within permissions.js
. I personally keep them together in one file but, hey, I am not telling you how to live your life. :-)
Let’s digest what’s happening here:
- We define a new function,
hasPermission
, using the same API signature that we decided on earlier. It takes the file (which comes from the back end) and the action we want to perform. - As a fail-safe, if, for some reason, the file is
null
or doesn’t contain anaccessLevel
property, we returnfalse
. Better be extra careful not to expose “secret” information to the user caused by a glitch or some error in the code. - Coming to the core, we check if
mappings
contains the action that we are looking for. If so, we can safely get its value (remember, it’s an array of roles) and check if our currently logged-in user has the role required for that action. This either returnstrue
orfalse
. - Finally, if
mappings
didn’t contain the action we are looking for (could be a mistake in the code or a glitch again), we returnfalse
to be extra safe. - On the last two lines, we don’t only export the
hasPermission
function but also re-export our constants for developer convenience. That way, we can import all utilities in one line.
import hasPermission, { actions } from "./permissions.js";
More use cases
The shown code is quite simple for demonstration purposes. Still, you can take it as a base for your app and shape it accordingly. I think it’s a good starting point for any JavaScript-driven application to implement user roles and permissions.
With a bit of refactoring, you can even reuse this pattern to check for something different, like licenses:
import { actions, licenses } from "./constants.js";
const mappings = new Map();
mappings.set(actions.MODIFY_FILE, [licenses.PAID]);
mappings.set(actions.VIEW_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.DELETE_FILE, [licenses.FREE, licenses.PAID]);
mappings.set(actions.CREATE_FILE, [licenses.PAID]);
function hasLicense(user, action) {
if (mappings.has(action)) {
return mappings.get(action).includes(user.license);
}
return false;
}
Instead of a user’s role, we assert their license
property: same input, same output, completely different context.
In my team, we needed to check for both user roles and licenses, either together or separately. When we chose this pattern, we created different functions for different checks and combined them in a wrapper. What we ended up using was a hasAccess
util:
function hasAccess(file, user, action) {
return hasPermission(file, action) && hasLicense(user, action);
}
It’s not ideal to pass three arguments each time you call hasAccess
, and you might find a way around that in your app (like currying or global state). In our app, we use global stores that contain the user’s information, so we can simply remove the second argument and get that from a store instead.
You can also go deeper in terms of permission structure. Do you have different types of files (or entities, to be more general)? Do you want to enable certain file types based on the user‘s license? Let’s take the above example and make it slightly more powerful:
const mappings = new Map();
mappings.set(
actions.EXPORT_FILE,
new Map([
[types.PDF, [licenses.FREE, licenses.PAID]],
[types.DOCX, [licenses.PAID]],
[types.XLSX, [licenses.PAID]],
[types.PPTX, [licenses.PAID]]
])
);
This adds a whole new level to our permission checker. Now, we can have different types of entities for one single action. Let‘s assume that you want to provide an exporter for your files, but you want your users to pay for that super-fancy Microsoft Office converter that you’ve built (and who could blame you?). Instead of directly providing an array, we nest a second Map inside the action and pass along all file types that we want to cover. Why using a Map, you ask? For the same reason I mentioned earlier: it provides some friendly methods like .has()
. Feel free to use something different, though.
With the recent change, our hasLicense
function doesn’t cut it any longer, so it’s time to update it slightly:
function hasLicense(user, file, action) {
if (!user || !file) {
return false;
}
if (mappings.has(action)) {
const mapping = mappings.get(action);
if (mapping.has(file.type)) {
return mapping.get(file.type).includes(user.license);
}
}
return false;
}
I don’t know if it’s just me, but doesn’t that still look super readable, even though the complexity has increased?
Testing
If you want to ensure that your app works as expected, even after code refactorings or the introduction of new features, you better have some test coverage ready. In regards to testing user permissions, you can use different approaches:
- Create snapshot tests for mappings, actions, types, etc. This can be achieved easily in Jest or other test runners and ensures that nothing slips unexpectedly through the code review. It might get tedious to update these snapshots if permissions change all the time, though.
- Add unit tests for
hasLicense
orhasPermission
and assert that the function is working as expected by hard-coding some real-world test cases. Unit-testing functions is mostly, if not always, a good idea as you want to ensure that the correct value is returned. - Besides ensuring that the internal logic works, you can use additional snapshot tests in combination with your constants to cover every single scenario. My team uses something similar to this:
Object.values(actions).forEach((action) => {
describe(action.toLowerCase(), function() {
Object.values(licenses).forEach((license) => {
it(license.toLowerCase(), function() {
expect(hasLicense({ type: 'PDF' }, { license }, action)).toMatchSnapshot();
expect(hasLicense({ type: 'DOCX' }, { license }, action)).toMatchSnapshot();
expect(hasLicense({ type: 'XLSX' }, { license }, action)).toMatchSnapshot();
expect(hasLicense({ type: 'PPTX' }, { license }, action)).toMatchSnapshot();
});
});
});
});
But again, there’re many different personal preferences and ways to test it.
Conclusion
And that’s it! I hope you were able to gain some ideas or inspiration for your next project and that this pattern might be something you want to reach for. To recap some of its advantages:
- No more need for complicated conditions or logic in your UI (components). You can rely on the
hasPermission
function’sreturn
value and comfortably show and hide elements based on that. Being able to separate business logic from your UI helps with a cleaner and more maintainable codebase. - One single source of truth for your permissions. Instead of going through many files to figure out what a user can or cannot see, head into the permissions mappings and look there. This makes extending and changing user permissions a breeze since you might not even need to touch any markup.
- Very testable. Whether you decide on snapshot tests, integration tests with other components, or something else, the centralized permissions are painless to write tests for.
- Documentation. You don’t need to write your app in TypeScript to benefit from auto-completion or code validation; using predefined constants for actions, roles, licenses, and such can simplify your life and reduce annoying typos. Also, other team members can easily spot what actions, roles, or whatever are available and where they are being used.
Suppose you want to see a complete demonstration of this pattern, head over to this CodeSandbox that plays around with the idea using React. It includes different permission checks and even some test coverage.
What do you think? Do you have a similar approach to such things and do you think it’s worth the effort? I am always interested in what other people came up with, feel free to post any feedback in the comment section. Take care!
The post Handling User Permissions in JavaScript appeared first on CSS-Tricks.
You can support CSS-Tricks by being an MVP Supporter.
source https://css-tricks.com/handling-user-permissions-in-javascript/
No comments:
Post a Comment