TL;DR
Learn to build a React file-sharing app implementing Relationship-Based Access Control (ReBAC) using Permit.io to manage granular user permissions (like owner, viewer) for specific files.
ReBAC defines permissions based on the relationship between users and resources. ReBAC is utilized to determine who can share what file.
Appwrite is a backend-as-a-service platform that provides authentication, storage, and database. Appwrite is used for authentication and storage
Permit.io is a fullstack authorization-as-a-service that helps you build and enforce permissions in your applications.
File-sharing applications have become essential tools for collaboration and information exchange. From collaborating on documents to sharing files, these applications play a crucial role in how we communicate.
However, one challenge remains: how do we know what file a user has access to and what can the user do with that file?
To address this issue, relationship-based access control (ReBAC) is an effective authorization model to enforce permissions in a file-sharing application. ReBAC allows us to define the relationship between a user and a particular file and enforce access rules based on that relationship.
In this tutorial, we’ll build a simple file-sharing application with the ReBAC authorization model using:
React for the frontend,
Appwrite for authentication, database, and storage, and
Permit.io for granular access control with relationship-based access control (ReBAC).
Before we go on with building the app, let’s understand what ReBAC is and the architectural design of our application.
What we’ll build
Our application is a file-sharing application. Users can upload and share files with other users.
What access policy we’ll be implementing
As stated earlier, we'll be using the relationship-based access control (ReBAC) authorization model for our file-sharing app.
What does this mean in terms of granting permissions?
It means that permissions are granted to a user based on their relationship to a particular file instance. For example, only users who have the 'owner' relationship with a file can perform the share action on that specific file. Similarly, users with the 'viewer' relationship to a file are granted view and download permissions for that exact file.
Desired application flow
In our file-sharing application, we’ll use Permit’s SDK to implement the file sharing feature and Appwrite for authentication, file upload, and cloud function (for interacting with Permit SDK).
Here’s our application flow:
When a user:
registers on our application, the user is synced to Permit through Appwrite cloud function.
uploads a file, a resource instance of that file is created in our Permit dashboard
wants to share a file with other users, we’ll use the Permit SDK’s
permit.check
to check if the user has the appropriate permission
Additionally, a user will specify the user to share with and what role to assign the user.
Prerequisites
To follow along with this tutorial, I assume you have:
Basic knowledge of JavaScript and React.
Node.js is installed on your machine.
Tech stack
Let's go over the tech stack we'll be using to build the file-sharing app.
React
React is a JavaScript library for building single-page frontend applications.
Appwrite
Appwrite is an open-source backend-as-a-service platform similar to Firebase and Supabase. Appwrite offers features such as authentication, database, storage, messaging, and cloud functions. For this tutorial, we'll be using the:
authentication,
database,
storage, and
cloud functions.
Permit.io
Permit.io is an authorization-as-a-service platform that lets you create and manage permissions separately from your application code.
Setting up the Development Environment
In this section, we'll set up our development environment.
Use the following commands to create a new React project and install the needed dependencies.
Create a React app with Vite:
npm create vite@latest
Follow the prompts to set up your project. For this tutorial, we'll use TypeScript.
Install the necessary dependencies:
npm install react-router-dom lucide-react appwrite
Setting Up the Appwrite Backend
In this section, we'll set up our Appwrite backend for authentication, database, and storage.
Setting up Appwrite:
Go to Appwrite and create an account if you don't have one.
Set up your organization.
The free plan is enough for the needs of our application.
Click on "Create project" to create a new project. Provide the name of the project and click on "Next".
For the deployment region, we'll use the default region. Click on "Create" to create the project.
Since our application is a web application, select the "Web" platform.
Provide the name of your project and the host name, "localhost". Click on "Next".
Skip the optional steps.
Setting up authentication
We'll use email/password for our authentication.
On the left panel, click on "Auth" and enable "email/password"
You can limit the number of sessions of a user. This means how many places a user can be logged in. For our application, we want a user to have only one session.
To accomplish this, go to the "Security" tab on the "Auth" section and limit sessions to one.
Setting up database
The database will be used to store the metadata of each file a user uploads.
Go to the "Database" section in the left panel and click on "Create database".
Provide the name "file_db" and click on "Create". Don't enter the Database ID, Appwrite will do that for us.
After creating the database, you'll be redirected to the "Collections" page. Click on "Create collection".
Provide the name of the collection "file-metadata" and create it.
After creating the collection, you'll be redirected to the "file-metadata" document page. Go to settings tab and scroll down to the "Permissions" and "Document security". Check all the permissions for "Users" and enable the document security, which allows only the user who uploaded the file can perform the allowed operations.
For the "file-metadata" document, we need to create attributes. Attributes are like schemas of what the document stores. Without attributes, you can't upload data to the database.
Go to "Attributes" tab and create four attributes:
fileName
(string, 256, required)fileId
(string, 256, required)ownerId
(string, 256, required)shared_with
(array of string, 1024)
Setting up storage
The Appwrite Storage will be used to store the actual files that users upload. These files can be anything, ranging from docs, images, scripts, to videos.
Go to the "Storage" on the left panel and click on "Create bucket"
Provide the name "files" and click on "Create".
After creating the bucket, you'll be redirected to the "files" bucket. Go to the "Settings" tab and add permissions for "Users"
Now we are done with setting up our Appwrite authentication, database, and storage, let's set up our authorization.
Setting up our Authorization
Planning our ReBAC implementation
Before we go into setting up our Permit setup, let’s map out our ReBAC structure clearly.
Resources and actions
Our file sharing app will only manage one resource:
-
files: Actions include:
share
,download
,delete
, andview
.
Relationships and actions
The following table defines the relationships users can have with file resource instances and the permissions granted based on those relationships.
Relationship | File resource |
---|---|
Owner | Full access (share, download, view, download) |
Viewer | View and download |
This is the permission structure of our simple file sharing app where:
File owners have full control over their files.
Viewers (users who a file is shared with and assigned “viewer” relationship) can only view and download files.
It's important to note that in ReBAC, these relationships are typically defined and enforced at the level of individual resource instances (in our case, each specific file).
Now, with the structure out of the way, let’s look at setting up the authorization structure in Permit.io.
Creating a Permit project
To use Permit.io, you’ll have to create a free account. After creating your account, go to the “Project” section in your dashboard and create a new project.
Then copy your API key.
After creating your environment, go to your dashboard and go to the "Policy" section on the left panel.
Select the "Resources" tab and click on "Add Resource". This will open a right form panel.
Enter the name of the resource (“file”) and assign the actions (“delete”, “download”, “share”, “view”). Scroll down to the “ReBAC Options” to define the roles for each resource (“owner”, “viewer”), then click “Save”.
Go to the "Policy Editor" tab and set up the permissions for the roles you just created.
Owner: has all permissions
Viewer: has permissions to only view and download file resources
Congratulations, we have set up Permit.io for our authorization.
Next, we are going to set up our application.
Frontend Setup with React
In this section, we're going to continue the set up for our application.
Configuring Appwrite in our React frontend
Retrieve the following from your Appwrite backend:
project ID
database ID
file-metadata collection ID
files bucket ID
Create a .env
file in the root directory of your React app. Inside the .env
file, add the API keys you retrieved from your Appwrite backend
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=your-project-id
VITE_FILE_METADATA_COLLECTION_ID=your-file-metadata-collection-id
VITE_DATABASE_ID=your-database-id
VITE_FILES_BUCKET_ID=files-bucket-id
Inside the src
directory, create a file configuration/appwrite.ts
and add the following code:
import { Client, Account, Databases, Storage } from "appwrite";
export const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID as string);
export const account = new Account(client);
export const database = new Databases(client);
export const storage = new Storage(client);
In the code above, we're setting up our Appwrite for authentication, database and storage.
Configuring Permit.io in our application
Permit only has server-side SDKs at the moment. This means that to integrate Permit’s ReBAC policy in our React application, we’ll need some sort of backend. We’ll use Appwrite cloud functions to integrate Permit in our React application.
Don’t let cloud functions scare you, they’re pretty straightforward to set up.
Navigate to your Appwrite console and select the “Functions” tab on the left panel. Click on “Create function” to create a new Appwrite function.
Click on ”All starter templates”
Click on “Create function”
Add the name of your function and choose Node.js for your function’s runtime. The “0.5 CPU, 512 MB RAM” is enough for this tutorial.
Click on “Next”
Leave the permissions as is and click on “Next”
In the “Deployment” section, choose “Connect later” — because we’ll use the CLI to develop and deploy our functions.
And click on “Create”.
Setting up our Appwrite function in our CLI
Navigate to your project directory in your terminal and run the following command to install the Appwrite CLI:
npm install appwrite-cli
You’d have to sign in using your Appwrite credentials:
appwrite login
Note: if you signed up to Appwrite using OAuth (GitHub or Google), you wouldn't have a password to login with in the CLI. To get your password, go to your account settings and put in a password.
Run the following command to initialize an Appwrite project:
appwrite init project
Choose “Link directory to an existing project” and follow the prompts.
Initialize an Appwrite function using the following command:
appwrite init function
and follow the prompts to set up your function
Run the following command to pull in the function code into your application folder:
appwrite pull function
Let’s understand how our Appwrite cloud function will work.
There are three main ways of executing an Appwrite cloud function:
through HTTP requests
event triggers
scheduling
We’re going to call our Appwrite function using HTTP and event triggers.
Our Appwrite function executes when a user:
signs up in our application
uploads a file
attempts to share a file
visits the file upload page
Now we are on the same page of how our application integrates with the Appwrite function, let’s move on with the application.
The Permit.io SDK requires an API key. To get your API key, go to the “Settings” section of your Permit.io dashboard and click on "API Keys".
Copy your API key, create a .env
file inside of the directory housing your Appwrite function, and add the API key as PERMIT_API_KEY
.
Permit.io requires a Policy Decision Point (PDP) to evaluate your authorization requests. Permit.io provides several ways to set up a PDP, but we'll set up our PDP using Docker (this is the recommended way by Permit.io).
You need to have Docker installed on your machine.
In your Permit.io dashboard, click on "Projects" in the left panel and click on "Connect" in your preferred environment.
Pull the PDP container from Docker Hub using the following command:
docker pull permitio/pdp-v2:latest
Run the container:
docker run -it \\\\
-p 7766:7000 \\\\
--env PDP_API_KEY=your-permit-api-key \\\\
--env PDP_DEBUG=True \\\\
permitio/pdp-v2:latest
Replace the PDP_API_KEY with your API key.
Note: if the above command fails to run, put the command in a single line like this:
docker run -it -p 7766:7000 --env PDP_API_KEY=your-permit-api-key --env PDP_DEBUG=True permitio/pdp-v2:latest
The PDP is now running on http://localhost:7766
. Navigating to the URL, you should see a message like this:
{
"status": "ok"
}
With Permit.io configured, update the functions/"your function name"/src/main.js
file with the following code:
const { Client, Users } = require('node-appwrite');
const { Permit } = require('permitio');
const permit = new Permit({
token: process.env.PERMIT_API_KEY_PROD,
pdp: 'PDP-endpoint',
});
// This Appwrite function will be executed every time your function is triggered
module.exports = async ({ req, res, log, error }) => {
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT || '')
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID || '')
.setKey(req.headers['x-appwrite-key'] ?? process.env.APPWRITE_API_KEY);
const users = new Users(client);
}
As a side note, you’ll have to set the type
property in your package.json
to commonjs
. I’ll be using main.js
to reference the Appwrite cloud function.
Additionally, get your Appwrite API key and add it to the .env
file.
Additionally, make sure to add the necessary events to your Appwrite function like this:
Building our application
This is the section where we'll build out our application.
Setting up authentication
In this section, we'll set up authentication for our app.
Here's what we want to achieve with our authentication system:
- When a user signs up in our application, we'll sync the user to Permit.io.
Create a new file, context/context.ts
. In this file, add the following code:
import { createContext, useContext } from "react";
interface ContextValue {
user: object | null;
loginUser: (email: string, password: string) => void;
logoutUser: () => void;
registerUser: (email: string, password: string, name: string) => void;
checkUserStatus: () => void;
loading: boolean;
}
export const AuthContext = createContext<ContextValue | null>(null);
export const useAuth = () => useContext(AuthContext);
In the code above, we're creating an auth context using the React Context API to manage authentication. The auth context has methods for signing in, registration, logging out, and a user's session.
Update the App.tsx
file with the following code:
function App() {
const [loading, setLoading] = useState<boolean>(true);
const [user, setUser] = useState<object | null>(null);
console.log(user);
// Login the user
const loginUser = async () => {}
// Logout the current user
const logoutUser = async () => {}
// Register a new user
const registerUser = async () => {}
// Check the status of the current user
const checkUserStatus = async () => {}
const contextData = {
user,
loginUser,
logoutUser,
registerUser,
checkUserStatus,
loading
};
return (
<AuthContext.Provider value={contextData}>
{loading ? (
<p>Loading...</p>
) : (
<BrowserRouter>
<header className="flex justify-center items-center p-2 mb-4 bg-purple-400">
<nav>
<ul className="flex gap-5">
{user ? (
<li>
<button onClick={logoutUser}>Log out</button>
</li>
) : (
<>
<li>
<NavLink to="/login">Sign in</NavLink>
</li>
<li>
<NavLink to="/register">Sign up</NavLink>
</li>
</>
)}
</ul>
</nav>
</header>
<Routes>
<Route element={<ProtectedRoute />}>
<Route element={<FileUploadPage />} path="/" />
</Route>
<Route element={<LoginPage />} path="/login" />
<Route element={<SignUpPage />} path="/register" />
</Routes>
</BrowserRouter>
)}
</AuthContext.Provider>
);
}
export default App;
In the App.tsx
file, we wrapped our entire application with AuthContext.Provider
. Additionally, we added routing using the react-router-dom
library.
Looking closely, you'll see that we're protecting our home page. This ensures that only authenticated users can access the home page.
Create a components folder. Inside the folder, create a ProtectedRoute.tsx
file and add the following code:
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/context";
function ProtectedRoute() {
const { user } = useAuth();
return user ? <Outlet /> : <Navigate to="/login" />;
}
export default ProtectedRoute;
In the code above, we're creating a ProtectedRoute
component that checks for a user and redirects an authenticated user to the login page.
Now, we're going to create the LoginPage
, SignUpPage
, and FileUploadPage
components.
LoginPage
Create a pages folder. Inside the pages folder, create a new file, LoginPage.tsx
, and add the following code snippets to create the Login
component:
import { NavLink, useNavigate } from "react-router-dom";
import { signIn } from "../configurations/appwrite";
import { FormEvent, useEffect, useState } from "react";
import { useAuth } from "../context/context";
function LoginPage() {
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const navigate = useNavigate();
const { user, loginUser } = useAuth();
useEffect(() => {
if (user) navigate('/');
});
We’re setting up our Login
component by importing required modules and hooks. We initialize state for the email and password inputs, retrieve authentication context via useAuth
, and use useEffect
to redirect the user if already logged in.
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const handleSignIn = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData();
formData.append("email", email);
formData.append("password", password);
const userEmail = formData.get("email") as string;
const userPassword = formData.get("password") as string;
loginUser(userEmail, userPassword);
};
This part of the component includes functions to handle changes for both email and password inputs. The handleSignIn
function processes form submission by creating a FormData
object and then invoking loginUser
with the gathered credentials.
return (
<div className="w-full max-w-full flex flex-col justify-center items-center h-screen">
<form onSubmit={handleSignIn} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input id="email" type="email" placeholder="Email" name="email" value={email} onChange={handleEmailChange} />
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input id="password" type="password" placeholder="Password" name="password" value={password} onChange={handlePasswordChange} />
</div>
<div className="flex items-center justify-between">
<button className="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded" type="submit">
Sign In
</button>
</div>
</form>
<p>
Don't have an account? <NavLink to="/register">Sign up</NavLink>
</p>
</div>
);
}
export default LoginPage;
This final snippet renders the UI through JSX. It displays a form with inputs for email and password, a submit button, and a link to the registration page (in case the user doesn’t have an account in our application).
Here’s the login page UI:
SignUpPage
Create a new file, SignUpPage.tsx
, and add the following code snippets:
import { useState, FormEvent, useEffect } from "react";
import { useAuth } from "../context/context";
import { useNavigate } from "react-router-dom";
function SignUpPage() {
const [email, setEmail] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [name, setName] = useState<string>("");
const { user, registerUser, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (user) navigate("/");
}, [user, navigate]);
The code above handles the initial setup: importing hooks and modules, initializing state variables for email, password, and name, and setting up context and navigation. The useEffect
hook redirects the user if they are already authenticated.
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
const handleSignUp = async (e: FormEvent) => {
e.preventDefault();
const formData = new FormData();
formData.append("email", email);
formData.append("password", password);
formData.append("name", name);
registerUser(formData.get("email") as string, formData.get("password") as string, name);
};
The code above focuses on the functions that update state as the user types and the handleSignUp
function, which processes the form submission. It builds a FormData
object for clarity before calling the registerUser
function.
return (
<div className="w-full max-w-full flex justify-center items-center h-screen">
<form onSubmit={handleSignUp} className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
Name
</label>
<input id="name" type="text" placeholder="Your name" value={name} onChange={handleNameChange} required />
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input id="email" type="email" placeholder="Email" value={email} onChange={handleEmailChange} required />
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input id="password" type="password" placeholder="Your password" value={password} onChange={handlePasswordChange} required />
</div>
<div className="flex items-center justify-between">
<button className="bg-purple-500 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded" type="submit" disabled={loading}>
Sign up
</button>
</div>
</form>
</div>
);
}
export default SignUpPage;
We’re rendering the JSX for the sign-up form. It includes input fields for name, email, and password, each tied to their corresponding change handlers.
Here’s the sign up page UI:
FileUploadP*age*
Create a new file, FileUploadPage.tsx
, and add the following code:
This FileUploadPage
component allows users to upload files, view file details, and check the upload status.
import { ReactNode, useState } from "react";
import { useAuth } from "../context/context";
function FileUploadPage() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<
"initial" | "uploading" | "success" | "fail"
>("initial");
const [isLoading, setIsLoading] = useState<boolean>(false);
The initial setup of our FileUploadPage
component:
Manages authentication state, selected file, upload status, and loading state
Tracks file selection progress and UI states
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) {
setStatus("initial");
setFile(e.target.files[0]);
}
}
async function handleUpload() {
if (file) {
setStatus("uploading");
setIsLoading(true);
const fileData = new FormData();
fileData.append("file", file);
console.log(file);
}
}
In this part of the code, we’re handling file selection from the file input field.
return (
<div className="py-8 px-8 flex flex-col gap-1 items-center">
<h1 className="text-2xl font-bold my-4">Welcome, {user.name}</h1>
<p className="text-base font-semibold my-3">
Upload, view, and share files with your friends
</p>
<form>
<input
type="file"
name="file"
id="file"
onChange={handleFileChange}
className="text-center cursor-pointer"
/>
</form>
<hr className="border w-full border-black my-8" />
{file && (
<section className="border-b w-1/3 text-wrap shadow-sm px-4 border-black my-4">
<ul className="flex flex-col gap-2 my-3">
<li className="font-semibold">{file.name}</li>
<li className="text-gray-400">{(file.size / 1000).toFixed(2)}KB</li>
</ul>
</section>
)}
{file && (
<button
onClick={handleUpload}
className="bg-blue-500 px-3 py-2 font-semibold cursor-pointer text-white rounded-md hover:bg-blue-300 transition-all"
disabled={isLoading}
>
Upload file
</button>
)}
<Result status={status} />
<hr className="border w-full border-black my-8" />
</div>
);
}
We’re returning the JSX for the UI.
function Result({ status }: { status: string }): ReactNode {
if (status === "success") {
return <p>âś… File uploaded successfully</p>;
} else if (status === "fail") {
return <p>❌ File upload failed</p>;
} else if (status === "uploading") {
return <p>⌛ Uploading selected file..</p>;
} else {
return null;
}
}
The Result
component handles the conditional rendering of status messages, by providing visual feedback using emoji indicators.
Next, we're going to create the loginUser
, registerUser
, checkUserStatus
, and logoutUser
functions.
Creating the authentication functions
loginUser function
In the App.tsx
file, update the loginUser
function with the following code:
const loginUser = async (email: string, password: string) => {
setLoading(true);
try {
await account.createEmailPasswordSession(email, password);
const loggedinUser = await account.get();
setUser(loggedinUser);
} catch (error) {
console.error(error);
}
setLoading(false);
};
The loginUser
function accepts email and password as arguments and creates a session using Appwrite's createEmailPasswordSession
method on the account
object. After a session is created, we set user
to loggedinUser
.
registerUser function
When registering users in our application, we need to sync them to Permit.io.
As a refresher, Permit.io doesn't have a client SDK and we would be using our Appwrite cloud function to interact with the Permit.io SDK.
In the main.js
of our Appwrite function, add the following code:
const data = req.bodyJson;
if (!data) return res.json({ ok: 'failed', message: 'No data found' });
const headers = req.headers;
const triggerType = headers['x-appwrite-trigger'];
if (triggerType === 'event' && data?.email) {
try {
const user = {
key: data?.email,
email: data?.email,
first_name: data?.name,
last_name: '',
attributes: {
tenant: 'default',
},
};
const syncedUser = await permit.api.users.sync(user);
if (!syncedUser) throw new Error('An Error occurred while syncing user');
log(syncedUser);
return res.json({
ok: true,
message: `${data?.name} synced successfully`,
});
} catch (error) {
return res.json({
ok: false,
message: `${error instanceof Error ? error.message : 'An unknown error occurred'}`,
});
}
}
}
In the code above, we’re listening for user registration in our application and syncing the registered user to our Permit dashboard.
Let’s understand what’s going on behind the scenes.
An Appwrite cloud function accepts a context
object with four properties, res
, req
, log
, and error
, which we destructured.
When an Appwrite function is triggered, the req
object receives two fields: headers
(for getting useful information on the function execution such as the trigger type and executing user) and bodyJson
(which contains the data from executing the Appwrite function).
We’re checking if the trigger type is an event, and if the data
object contains an email
field (this is so that we know that what triggered the function is user registration).
If true, we construct a user
object using the user’s email and name and pass the user
object to the sync()
method, which creates a new user in Permit.io.
Navigate to the App.tsx
file and update the registerUser
function with the following code:
const registerUser = async (email: string, password: string, name: string) => {
setLoading(true);
try {
const resultFromUserRegistration = await account.create(ID.unique(), email, password, name);
await account.createEmailPasswordSession(email, password);
const registeredUser = await account.get();
setUser(registeredUser);
} catch (error) {
console.error(`An error occurred: ${error}`);
}
setLoading(false);
}
In the code above, we're registering a user to our Appwrite backend with account.create()
and syncing the user to Permit in the process due to our Appwrite cloud function.
logoutUser
function
Update the logoutUser
function with the following code:
const logoutUser = async () => {
await account.deleteSession("current");
setUser(null);
};
The logoutUser
function logs a user out using deleteSession
method of the account
object.
checkUserStatus
function
Update the checkUserStatus
with the following code:
const checkUserStatus = async () => {
try {
const user = await account.get();
setUser(user);
} catch (error) {
console.error(error);
}
setLoading(false);
};
In the code above, we're retrieving and setting the user.
Next, we are going to implement the file upload and sharing feature.
Building the File Upload and Sharing Flow
Implementing the file upload functionality
When a user uploads a file, we store the file in Appwrite Storage and the file metadata (shared_with
, fileId
, ownerId
) to the Appwrite database. Also, we create an instance of the file resource in Permit.io.
In the main.js
file of our Appwrite function, add the following code:
if (triggerType === 'event' && data.bucketId) {
try {
const user = await users.get(headers['x-appwrite-user-id']);
const resourceInstance = await permit.api.resourceInstances.create({
resource: 'file',
key: data.$id,
tenant: 'default',
});
const resource_instance = `file:${data.$id}`;
const assignedRoleToUser = await permit.api.roleAssignments.assign({
user: user?.email,
role: 'owner',
resource_instance,
});
log(assignedRoleToUser, resourceInstance);
return res.json({
ok: true,
message: `Resource instance created and the owner is ${user?.name}`,
});
} catch (err) {
error(err);
return res.json({
ok: false,
message: `There was error adding file resource`,
});
}
}
In the code above, we’re checking if the trigger type is an event and that the data
returned has a bucketId
field (so that we know that event is that of a file upload).
What’s going on behind the scenes?
Like I said, the headers
object contains important information including the calling user’s ID through the x-appwrite-user-id
property. We will combine the value of the x-appwrite-user-id
and the users
object to get the calling user’s email.
Since the data
object contains details of the uploaded file, we created a resource instance in our Permit dashboard using the file ID and the calling user’s email.
Finally, in the FileUploadPage.tsx
file, update the handleUpload
function with the following code:
async function handleUpload() {
if (file) {
setStatus("uploading");
setIsLoading(true);
const fileData = new FormData();
fileData.append("file", file);
try {
const fileId = ID.unique();
// store file in Appwrite Storage
const createdFile = await storage.createFile(
import.meta.env.VITE_FILES_BUCKET_ID,
fileId,
file
);
// store metadata in database
const fileMetadata = await database.createDocument(
import.meta.env.VITE_DATABASE_ID,
import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
fileId,
{
fileName: file.name,
fileId: createdFile.$id,
ownerId: user.$id,
shared_with: [],
}
);
toast.success("File uploaded successfully");
setStatus("success");
setIsLoading(false);
} catch (error) {
console.log(error);
if (error instanceof Error) toast.error(error.message);
setStatus("fail");
} finally {
setIsLoading(false);
setStatus("initial");
setFile(null);
}
}
}
The handleUpload
function handles the file upload process. First, it checks if a file is selected and sets the upload status to "uploading" while loading is enabled. The file is appended to a FormData
object, and then:
the file is uploaded to Appwrite's storage using
createFile
, generating a unique file ID.The file metadata (
fileName
,ownerId
,shared_with
, andfileId
) is stored in the Appwrite database usingcreateDocument
.A resource instance is created in Permit.io automatically through the Appwrite cloud function.
If the upload is successful, the status is set to "success", and a success toast is shown. In case of failure, it shows an error toast and sets the status to "fail". The function resets the loading state and the file once the process is complete.
Displaying files associated with a user
When the file upload page loads, we want to fetch files associated with the current user, i.e. files owned by the user and files shared with the user by other users.
To implement this, add the following code to the configurations/appwrite.ts
file:
import { Client, Account, Databases, Storage, Query, Models, Functions } from "appwrite";
export interface FunctionPromiseReturnType extends Models.Execution {
ok?: boolean;
message?: string;
role?: string | string[];
permitted?: boolean;
}
export interface Document extends Models.Document {
ownerId: string;
fileId: string;
fileName: string;
shared_with: string[];
}
export const client = new Client()
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID as string);
In this part of the file, we’re importing the necessary Appwrite modules and defining two TypeScript interfaces for function execution responses and document metadata. Additionally, we initialized the Appwrite client with the endpoint and project ID from environment variables.
export const account = new Account(client);
export const database = new Databases(client);
export const storage = new Storage(client);
export const functions = new Functions(client);
Here, we create and export instances for managing user accounts, databases, storage, and functions. These instances enable interaction with various Appwrite services throughout your application.
// Fetch metadata for owned and shared files
export const fetchMetadata = async (userId: string, userEmail: string) => {
try {
const ownedFilesMetadata = await database.listDocuments(
import.meta.env.VITE_DATABASE_ID,
import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
[Query.equal("ownerId", userId)]
);
const sharedFilesMetadata = await database.listDocuments(
import.meta.env.VITE_DATABASE_ID,
import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
[Query.contains("shared_with", [userEmail])]
);
return {
ownedFilesMetadata: ownedFilesMetadata.documents,
sharedFilesMetadata: sharedFilesMetadata.documents,
};
} catch (error) {
console.log(error);
return { ownFilesMetadata: [], sharedFilesMetadata: [] };
}
};
// Fetch file URL from storage
export const fetchFileFromStorage = async (fileId: string) => {
try {
return storage.getFileView(import.meta.env.VITE_DATABASE_ID, fileId);
} catch (error) {
console.log(error);
return null;
}
};
This part of the code contains two functions:
fetchMetadata
queries the database to get files owned by or shared with a user, whilefetchFileFromStorage
retrieves a file’s URL from storage. Both functions include basic error handling.
export const fetchFilesWithUrl = async (files: Document[]) => {
return Promise.all(
files.map(async (file) => {
try {
const fileUrl = await fetchFileFromStorage(file.fileId);
return { ...file, fileUrl };
} catch (error) {
console.log(error);
return file;
}
})
);
};
export async function fetchFilesWithUserPermission(userId: string, userEmail: string) {
const { ownedFilesMetadata, sharedFilesMetadata } = await fetchMetadata(userId, userEmail);
const ownedFilesWithUrl = await fetchFilesWithUrl(ownedFilesMetadata);
const sharedFilesWithUrl = await fetchFilesWithUrl(sharedFilesMetadata);
const processFiles = async (files: Document[]) => {
return Promise.all(
files.map(async (file) => {
const body = {
endpoint: "check-user-permission",
userKey: userEmail,
fileId: file.fileId,
action: "share"
};
const execution = await functions.createExecution(import.meta.env.VITE_FUNCTION_ID, JSON.stringify(body));
const parsedExecution = JSON.parse(execution.responseBody);
return { ...file, canShare: parsedExecution?.permitted };
})
);
};
const ownedFilesWithRole = await processFiles(ownedFilesWithUrl);
const sharedFilesWithRole = await processFiles(sharedFilesWithUrl);
return [...ownedFilesWithRole, ...sharedFilesWithRole];
}
In this final snippet, we created two functions:
fetchFilesWithUrl
attaches a file URL to each document, whilefetchFilesWithUserPermission
gathers metadata, augments files with their URLs, and checks user permissions for sharing each file by calling a backend function. Error handling is performed usingtry...catch
blocks.
In the main.js
of our Appwrite function, add the following code:
if (triggerType === 'http' && data.endpoint === 'get-user-role') {
const { userKey, fileId } = data;
const resourceInstance = `file:${fileId}`;
try {
const role = await permit.getUserPermissions(
userKey,
['default'],
[resourceInstance]
);
log(role);
return res.json({ ok: true, role: role[`file:${fileId}`]?.roles });
} catch (err) {
error(err);
return res.json({
ok: false,
message: `An error occurred while fetching user role: ${err instanceof Error ? err.message : 'An unexpected error occurred'}`,
});
}
}
if (triggerType === 'http' && data.endpoint === 'check-user-permission') {
const { userKey, fileId, action } = data;
const resourceInstance = `file:${fileId}`;
try {
const permitted = await permit.check(userKey, action, resourceInstance);
log(permitted);
return res.json({ ok: true, permitted });
} catch (err) {
error(err);
return res.json({
ok: false,
message: `${err instanceof Error ? err.message : 'An unknown error occurred'}`,
});
}
}
In the code above, we’re checking if the trigger type is a HTTP method (function.createExecution
uses HTTP to execute an Appwrite function). Also, we’re extracting the necessary data from the data object (provided when we called the cloud function in the appwrite.ts
file using function.createExecution
) and getting a user’s role for a particular file and checking for a user’s permission to share a file respectively.
Let's create a FileList
component for displaying each file:
import { Download, FileText, Share2 } from "lucide-react";
import { useRef, useState } from "react";
import ShareModal from "./ShareModal";
import { storage } from "../configurations/appwrite";
interface FileListProps {
fileName: string;
isOwner?: boolean;
fileId: string;
}
We’re importing necessary icons, hooks, and components. We also define the FileListProps
interface to specify the expected props for the component, including the file name, file ID, and an optional ownership flag.
function FileList({ fileName, fileId }: FileListProps) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [fileID, setFileID] = useState(fileId);
// Opens the share modal and locks page scrolling
const handleShareFile = () => {
setFileID(fileId);
dialogRef.current?.showModal();
document.body.style.overflow = "hidden";
console.log(fileID);
};
// Initiates file download by redirecting to the download URL
const handleDownloadFile = () => {
const result = storage.getFileDownload(
import.meta.env.VITE_FILES_BUCKET_ID,
fileID
);
window.location.href = result;
};
const closeDialog = (): void => {
dialogRef.current?.close();
document.body.style.overflow = "visible";
};
We initialize a ref for the share modal and a state variable for the file ID. Event handlers are included for sharing and downloading files, as well as a function to close the modal. Each handler performs a specific action: opening the modal, triggering a download, or resetting page scroll.
return (
<div>
<FileText size={100} />
<h3>{fileName}</h3>
<div>
<button onClick={handleShareFile}>
Share <Share2 size={20} />
</button>
<button onClick={handleDownloadFile}>
Download <Download size={20} />
</button>
</div>
<ShareModal
fileId={fileID}
dialogRef={dialogRef}
closeDialog={closeDialog}
/>
</div>
);
}
export default FileList;
We’re rendering the UI for the file list. It displays the file icon and name, along with buttons for sharing and downloading.
The ShareModal
component is used to share the file. The fileID
, dialogRef
, and closeDialog
, as props to the ShareModal
component.
We'll create the ShareModal
component when we build the sharing feature.
Here's a demo of the file upload feature:
Building the file sharing feature
This is the final stage of our application. In this section, we're going to build the sharing feature.
When the user clicks on the "Share" button, a modal pops up with a form of two fields where the user puts in the email of the person to share the file with and selects the role to assign to such person for that specific file.
First, let's create the ShareModal
component.
import React, { RefObject, useState } from "react";
import { shareFile } from "../actions/actions";
import { toast } from "react-toastify";
interface ShareModalProps {
dialogRef: RefObject<HTMLDialogElement>;
fileId: string;
closeDialog: () => void;
}
function ShareModal({ fileId, closeDialog, dialogRef }: ShareModalProps) {
const [email, setEmail] = useState("");
const [role, setRole] = useState("viewer");
// Further code in subsequent snippets...
We’re setting up the component by importing necessary modules, defining the prop types, and initializing state for managing the email and role values.
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
const handleRoleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setRole(e.target.value);
};
async function handleFileShare(e: React.ChangeEvent<HTMLFormElement>) {
e.preventDefault();
if (!email) return;
try {
const res = await shareFile(fileId, email, role);
if (!res.success) throw new Error(res.message);
toast.success(`File shared successfully with ${email}`);
closeDialog();
} catch (error) {
if (error instanceof Error) toast.error(error.message);
closeDialog();
} finally {
setEmail(""); setRole("viewer");
}
}
In the code above, we defined the event handlers for updating state on input changes and handling the form submission. The submission logic performs validation, calls the shareFile
function, and provides user feedback with notifications.
return (
<dialog ref={dialogRef} className="h-1/3 w-1/3 py-3 px-2">
<div className="text-right">
<button onClick={closeDialog} className="bg-gray-500 px-3 py-1 rounded hover:bg-red-500">
X
</button>
</div>
<form onSubmit={handleFileShare} className="flex flex-col gap-4 px-8 py-4">
<div>
<label htmlFor="email">Email:</label>
<input type="text" id="email" value={email} onChange={handleEmailChange} className="border rounded w-4/6 py-1 px-3" />
</div>
<div>
<label htmlFor="select-role">Role:</label>
<select id="select-role" value={role} onChange={handleRoleChange} className="border rounded py-1 px-1">
<option value="owner">owner</option>
<option value="viewer">viewer</option>
</select>
</div>
<div className="flex gap-8">
<button type="submit" className="bg-gray-200 hover:bg-gray-400 py-2 px-4 rounded">Share</button>
<button onClick={closeDialog} className="bg-red-500 hover:bg-red-700 py-2 px-4 rounded">Cancel</button>
</div>
</form>
</dialog>
);
}
export default ShareModal;
In the code above, we’re returning the modal’s JSX which includes:
the dialog container,
a close button,
and a form with inputs for email and role, along with submit and cancel buttons.
Now, let's create the shareFile
function.
However, to create the file sharing functionality, we need to first update our Appwrite function so that it updates the role of the user who the file is being shared with accordingly.
In the main.js
file of our Appwrite function, add the following code:
if (triggerType === 'http' && data.endpoint === 'update-user-role') {
const { sharedUserKey, role, fileId, requesterEmail } = data;
const resourceInstance = `file:${fileId}`;
try {
const isPermitted = await permit.check(
requesterEmail,
'share',
resourceInstance
);
if (!isPermitted)
return res.json({
ok: false,
message: "You don't have permission to share this file",
});
const roleAssignment = await permit.api.roleAssignments.assign({
user: sharedUserKey,
role,
resource_instance: resourceInstance,
});
log(roleAssignment);
return res.empty();
} catch (err) {
error(err);
return res.json({
ok: false,
message: `${err instanceof Error ? err.message : 'An unidentified error occurred'}`,
});
}
}
When a user attempts to share a file with another user, the permit.check()
function is crucial. Here, we are not just checking if the user has a general 'share' permission, but if they have the 'share' permission based on their relationship (in this case, implicitly 'owner') to the specific file resource instance identified by resourceInstance
.
Creating the function for sharing files
The next step after our permissions evaluation process in the cloud function addresses the shareFile
function's execution for practical file sharing capabilities. The function implements the sharing rules while maintaining secure file processing and efficient handling of all file share requests.
import { account, database, Document, FunctionPromiseReturnType } from "../configurations/appwrite";
import { functions } from "../configurations/appwrite";
The necessary modules together with types come from our Appwrite configuration file: configurations/appwrite.ts
. Throughout this fragment we utilize account
to handle user management and database
to access the database and functions
to run our cloud function. Document
and FunctionPromiseReturnType
serve as types that provide type safety when operating with Appwrite responses.
export async function shareFile(
fileId: string,
userEmail: string,
role: string
) {
const user = await account.get();
The shareFile
function is defined to accept a file ID, a user's email, and a role as parameters. The first operation within the function is retrieving the current user information using account.get()
. This is crucial to validate the user's identity before proceeding with the file sharing process.
try {
const currentDoc: Document = await database.getDocument(
import.meta.env.VITE_DATABASE_ID,
import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
fileId
);
Inside the try
block, the function fetches the file metadata from the database using database.getDocument()
. The document contains essential details like the file owner and the list of users with whom the file has been shared with.
if(!user) throw new Error("Unauthorized: User not found");
if(currentDoc.shared_with.includes(userEmail)) throw new Error("Error: User already has access to this file");
if(currentDoc.ownerId === user.$id && userEmail === user.email) throw new Error("Error: You can't share a file with yourself");
Before proceeding, the function performs several validations:
User Existence: It checks whether a user was successfully retrieved.
Duplicate Sharing: It ensures the file isn’t already shared with the given email.
Self-sharing: It prevents the owner from sharing the file with themselves.
These checks prevent unnecessary function calls and potential misuse.
const data = {
sharedUserKey: userEmail,
role,
fileId,
requesterEmail: user?.email,
endpoint: "update-user-role"
}
const updateRoleResult: FunctionPromiseReturnType = await functions.createExecution(import.meta.env.VITE_FUNCTION_ID, JSON.stringify(data));
Here, a data
object is prepared with all necessary details to update the user role on the file. The functions.createExecution()
method is then called, passing the function ID and the data (converted to a JSON string). This executes our cloud function which handles the role update logic.
if (updateRoleResult?.ok === false && updateRoleResult?.message === "You don't have permission to share this file") {
throw new Error("Unauthorized: You don't have permission to update user role");
}
if (updateRoleResult?.ok === false) throw new Error("An unknown error occurred");
After executing the role update function, the code checks if the execution was successful:
If the function indicates a permission error, an appropriate error is thrown.
A generic error is thrown if the update fails for other reasons.
This ensures that any failure in the role update process is properly communicated back to the caller.
const result = await database.updateDocument(
import.meta.env.VITE_DATABASE_ID,
import.meta.env.VITE_FILE_METADATA_COLLECTION_ID,
fileId,
{
shared_with: [...currentDoc.shared_with, userEmail],
}
);
If the role update is successful, the file metadata is updated to include the new user email in the shared_with
array. This is done using the database.updateDocument()
method.
return {
success: true,
message: updateRoleResult?.message,
};
} catch (error) {
if (error instanceof Error) {
console.error(error.message);
return {
success: false,
message: error.message,
}
};
return {
success: false,
message: "An error unknown occurred while sharing the file",
};
}
}
Finally, the function returns a success response containing the message from the role update function. If any error is encountered during the process, the catch
block:
captures it,
logs the error message, and
returns an error response with the relevant message.
This ensures robust error handling and clear communication of issues.
With the shareFile
function fully defined, add it to your actions/action.ts
file. Now, users can easily share files with one another.
Handling file download
Add the following code to the FileList.tsx
file to handle file downloads:
const handleDownloadFile = () => {
const result = storage.getFileDownload(
import.meta.env.VITE_FILES_BUCKET_ID,
fileID
);
window.location.href = result;
};
Deploying our Appwrite function
We need to deploy our function so that our React application can invoke it.
Use the following command to deploy our Appwrite cloud function:
appwrite push function
Here’s the Demo of the application:
when there’s an error sharing a file
when a user shares a file successfully
Create other users and share files with them. Log in with those users’ credentials and you’ll see the files shared with them.
Congratulations! You've built a file-sharing web application using Permit.io, Appwrite and React.
Fixing Appwrite function not accessing your Permit PDP
Since our PDP is running locally, our deployed Appwrite function doesn’t have access to it and therefore, will cause the function execution to fail.
To fix this, we have to make our PDP publicly accessible. And we’re going to do that by using Ngrok.
Ngrok is a cross-platform application that creates secure tunnels (paths) to localhost machine. It enables developers to expose a local development server to the Internet with minimal effort.
Steps to expose our PDP using Ngrok
Install Ngrok (if you haven’t already)
Download it from ngrok.com and follow the installation steps for your OS.
Authenticate using
ngrok authtoken <your-ngrok-token>
(find your token in your ngrok dashboard).
Expose Your Permit PDP Container
- Assuming your Permit PDP container is running on port 7766, use the following command to expose it:
ngrok http http:localhost:7766
Make sure your Docker container is running.
This will generate a public URL like: “https://d143-105-116-13-106.ngrok-free.app”.
Update your PDP in the Appwrite function:
const permit = new Permit({
token: process.env.PERMIT_API_KEY_PROD,
pdp: '<https://d143-105-116-13-106.ngrok-free.app>',
});
And deploy again using:
appwrite push function
Now, our Appwrite function has access to our Permit PDP and should execute successfully.
Troubleshooting
Make sure your Docker container PDP and Node/Express server is running. Else, you will not be able to interact with Permit.io.
Additionally, if you have issues deploying your Appwrite function, update your appwrite.json
file by adding the following fields:
"cpu": 1,
"memory": 512,
And remove the specification
field.
Conclusion
In this tutorial, you learned how to build a file-sharing application using the power of Relationship-Based Access Control (ReBAC). We've shown how ReBAC allows for fine-grained control over file access by defining permissions based on the relationships between users and individual files, offering a more flexible and secure authorization model compared to traditional role-based approaches.
Resources
Thankyou for reading! If you found this article useful, share it with your peers and community.
If You ❤️ My Content! Connect Me on Twitter
Check SaaS Tools I Use 👉🏼Access here!
I am open to collaborating on Blog Articles and Guest Posts🫱🏼‍🫲🏼 📅Contact Here
Top comments (8)
Great One 🔥
Thankyou :)
Dang, setting up file sharing with all those checks feels like building a super safe treehouse with a secret password and a lock for each buddy who visits but it's pretty good
because why not, thanks Nevo!
great.
thanks
hi
hey?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.