How to handle front-end authorization using Laravel, Inertia and TypeScript
Recently Jeffrey Way published a video titled "Frontend Authorization Brainstorming" on Laracasts. In that video, he shows three ways of passing authorization results to the front-end.
Currently I'm working on a big project that uses Inertia, React and TypeScript. In this blog post, I won't cover those things in detail, but I'd like to show you have we, using those technologies, pass authorization (and routes) to the front-end.
Using policies
In the app I'm working on the are teams and projects. A team owns a project. A project can also be accessible by guests.
In our app, all of our authorization checks are made using Laravel Policies. Here's the policy for projects.
namespace App\Domain\Project\Policies;
use App\Domain\Project\Models\Project;
use App\Domain\Team\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class ProjectPolicy
{
public const ADMINISTER = 'administer';
public const LEAVE = 'leave';
use HandlesAuthorization;
public function administer(User $user, Project $project)
{
if (! $user->hasTeamWithProject($project)) {
return false;
}
return $user->isAdminOrOwner($project->team);
}
public function leave(User $user, Project $project)
{
return $user->isGuestOnProject($project);
}
}
Don't mind the logic in those methods, that's not the focus. Do notice that for every method in our policy there is a constant with the same name. In a small project, you won't do this. But this code is taken from quite a sizeable app, with many policies each having different methods on it. By having a constant on your can do a gate check like this:
Gate::allows(ProjectPolicy::ADMINISTER, $project);
Why starting to type ProjectPolicy
any decent IDE will show you the constants you have on your policy.
Using a constant also has a benefit that changing the name of a policy method becomes easy. Just change the method name and using your IDE perform a rename refactor on the constant. A decent IDE can update all usages of the constant.
Using resources
In our app, we're using Inertia. It's a very cool collection of packages that Jonathan Reinink currently is building. If you want to know more about the project, read this blog post.
Using Inertia, each page is its own React (or Vue component). So in our app Blade isn't used to render anything. So we can't use serverside logic when rendering our React powered views.
This is what our ProjectsIndexController
looks like:
namespace App\Http\App\Controllers\Projects;
use App\Http\App\Resources\Project\ProjectResource;
use Inertia\Inertia;
class ProjectsIndexController
{
public function __invoke()
{
$projects = $this->getProjectsForCurrentUser();
return Inertia::render('projects.index', [
'projects' => ProjectResource::collection($projects),
]);
}
}
The important bit here is that a collection of projects is passed to the ProjectResource
, which is an API Resource. An API resource in Laravel is a dedicated class to transform an Eloquent model into an API response. Let's take a look at that ProjectResource
.
namespace App\Http\App\Resources\Project;
use App\Domain\Project\Policies\ProjectPolicy;
use App\Http\App\Controllers\Projects\Settings\DeleteProjectController;
use App\Http\App\Controllers\Projects\Settings\LeaveProjectController;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
// ... other attributes redacted for brevity
'can' => [
ProjectPolicy::ADMINISTER => auth()->user()->can(ProjectPolicy::ADMINISTER, $this->resource),
ProjectPolicy::LEAVE => auth()->user()->can(ProjectPolicy::LEAVE, $this->resource)
],
'links' => [
'edit' => action(EditProjectController::class, $this->resource),
'delete' => action([DeleteProjectController::class, 'show'], $this->resource),
'leave' => action([LeaveProjectController::class, 'show'], $this->resource),
],
];
}
}
In all resources we use the can
key to put authorization results that are of interest to the front-end. The key of each entry in that array is the name of the policy method, the value is the result of the check: true
or false
.
Routes that are of interest to the front-end are put in the links
key. The front-end can use the routes to render links to the detail screen, the delete endpoint and so on. We can use the action
helper and the fully qualified class name because we've removed the default controller namespace.
At the front-end
In this project, we use TypeScript to define custom types. Each API resource has a matching type. Here's the type definition for Project
:
declare type Project = {
id: number;
name: string;
can: {
administer: boolean;
leave: boolean;
};
links: {
show: string;
delete: string;
leave: string;
};
};
Here's the project.index
React component that renders the list of projects.
import React from 'react';
import Layout from 'app/views/layouts/Layout';
import {can} from "app/util";
type Props = {
projects: Array<Project>;
};
export default function index({projects}: Props) {
return (
<Layout title="Projects">
<table>
<th>
<td>Name</td>
<td> </td>
</th>
{projects.map(project => (
<tr>
<td>{project.name}</td>
<td>{can('administer', project) && <a href={project.links.edit}>Edit</a>}</td>
<td>{can('leave', project) && <a href={project.links.leave}>Leave</a>}</td>
</tr>
))}
</table>
</Layout>
);
}
Let's take a look at the things that are happening here. Remember those projects
we passed to Inertia::render
? Behind the scenes, Inertia will take care that those projects are being passed to the React component above as a projects
prop. Using TypeScript, we explicitly say that the projects
prop is an array of Project
objects.
type Props = {
projects: Array<Project>;
};
export default function index({projects}: Props) {
// ...
IDEs that support TypeScript can now autocomplete the properties on a Project
object. So when typing project.links
the IDE can show us the available links:
Let's turn our attention to the can
method. It was created by my colleague Seb. This is its definition:
export function can<T extends Authorizable>(ability: keyof T['can'] & string, authorizable: T) {
return authorizable.can[ability];
}
This function will check if the can
property of the object being passed as the second argument contains a key that's being passed as the first argument. With this in place, can('administer', project)
will return a boolean (the result of the authorization check). If we try to use a non-existing check, the IDE will warn us.
Closing thoughts
I hope you enjoyed this walkthrough of how we pass authorization checks (and routes) to the front end. In essence, we add a can
and links
entry to the API resource. On the front-end, we use TypeScript to enable autocompletion and error detection.
What are your thoughts on "How to handle front-end authorization using Laravel, Inertia and TypeScript"?