TL;DR Learn how to build a secure, real-time collaborative document editor with Next.js, Appwrite, Liveblocks, and Permit.io using ReBAC for flexible and secure relationship-based access control. Features include: Real-time collaboration with presence awareness. Secure storage and login management. Scalable permissions for complex access needs. This guide offers a strong foundation for building advanced collaborative tools. To make it production-ready, you’ll need to implement additional steps like error handling and conflict resolution based on your requirements. Introduction Collaborative tools such as Figma, Google Docs, and Notion are essential for teams spread across different locations or time zones. These platforms simplify working together, but they also introduce challenges like ensuring the right people have the right access without compromising sensitive information. That’s where proper access control comes into play. In this guide, we will build a collaborative document editor using Next.js for the front end, Appwrite for authentication and storage, Liveblocks for smooth collaboration, and Permit.io to manage advanced authorization with Relationship-Based Access Control (ReBAC). Prerequisites Before we start, make sure you have these tools installed on your computer Node.js (v18 or later) Npm (v10 or later) You should also know the basics of React and TypeScript, as we’ll be using them in this tutorial. Tools we will use To build the real-time collaborative document editor, we will use the following tools. Let’s discuss their purpose and how they work together. Appwrite Appwrite is an open-source backend-as-a-service platform that offers solutions for authentication, databases, functions, storage, and messaging for your projects using the frameworks and languages you prefer. In this tutorial, we will use its authentication and storage solutions for user login/signup and to store the document content. Liveblocks Liveblocks is a platform that lets you add collaborative editing, comments, and notifications to your app. It offers a set of tools that you can use to include collaboration features, so you can pick what you need based on your needs. Liveblocks works well with popular frontend frameworks and libraries, making it easy to quickly add real-time collaboration to any application. Permit.io Building authorization logic from scratch takes a lot of time. Permit.io makes this easier by providing a simple interface to manage permissions separately from your code. This keeps your access rules organized, simplifies management, and reduces the effort needed to maintain your code. In this tutorial, we will use Relationship-Based Access Control (ReBAC), an authorization model that is more flexible than traditional role-based access control. ReBAC allows you to set access rules based on the relationships between users and resources. For our document editor, this means we can easily set up permissions like: Document owners have full control over their documents Editors are able to change content but not delete the document Viewers having read-only access Next.js Next.js is a popular framework for building server-side rendered web applications quickly and efficiently. Here is the application structure. ./src ├── app │ ├── Providers.tsx │ ├── Room.tsx │ ├── api │ │ └── liveblocks-auth │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ └── room │ └── page.tsx ├── components │ ├── Avatars.module.css │ ├── Avatars.tsx │ ├── ConnectToRoom.module.css │ ├── ConnectToRoom.tsx │ ├── Editor.module.css │ ├── Editor.tsx │ ├── ErrorListener.module.css │ ├── ErrorListener.tsx │ ├── Loading.module.css │ ├── Loading.tsx │ ├── Toolbar.module.css │ └── Toolbar.tsx ├── globals.css ├── hooks │ └── useRoomId.ts └── liveblocks.config.ts Alright, we talked about the tools we’ll use and looked at the project structure. Now, let’s start setting up the development environment. Setting Up the Development Environment Let’s start by creating a new Next.js project and installing the needed dependencies. npx create-next-app@latest collaborative-docs --typescript cd collaborative-docs For components, we will use shadcn UI, so let’s set it up. npx shadcn@latest init This will install shadcn in our project. Now, let’s add the components. npx shadcn@latest add alert alert-dialog avatar button card checkbox command dialog dropdown-menu form input label popover toast tooltip tabs Setup Appwrite First, we need to set up Appwrite for authentication and document storage. Visit https://cloud.appwrite.io/, sign up, then set up the organization and select the free plan, which is enough for our needs. Click on the “Create Project” button and go to the project creation form. Provide the project name “Collaborative Docs” and click on “Next.” We will use the default region and then click on “Create.” We will be using Appwrite for our web application, so let’s add a Web platform. Provide the name “Collaborative Docs” and the hostname “localhost,” then click next. We can skip the optional steps and move forward. To authenticate users, we will use the email/password method. Go to the Auth section in the left panel, enable “email/password,” and disable other methods. Next, go to the security tab and set the maximum session limit to 1. Alright, we have set up the authentication method. Now, let’s create a database and a collection to store the data. Go to the “Databases” section in the left panel and click on “Create Database”. Then provide the name “collaborative_docs_db” and create it. After creating the database, you will be redirected to the collections page. From there, click on the “Create collection” button. Then provide the name “document_collection” and create it. After creating the collection, you will be redirected to the “document_collection” page. Then, go to the “Settings” tab, scroll down to permissions and document security, and add permissions for “Users.” Enforce document security so that only the user who created the document can perform the allowed operations. Go to the “Attributes” tab and create four attributes: roomId (string, 36, required) storageData (string, 1073741824) title (string, 128) created_by (string, 20, required) At this point, you should have four attributes set up for the document. Alright, the platform setup is done. Now, let’s install the Appwrite dependencies. npm install appwrite node-appwrite@11.1.1 Create a .env.development.local file to store keys and secrets as environment variables. NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 NEXT_PUBLIC_APPWRITE_PROJECT_ID=your_project_key Create a new file lib/appwrite.ts import { Client, Account, Databases } from 'appwrite'; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || '') // Your API Endpoint .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || ''); // Your project ID; export const account = new Account(client); export const databases = new Databases(client); export const APPWRITE_CLIENT = { account, databases, }; Great, the Appwrite setup is done. Now, let’s set up Liveblocks for real-time collaboration. Setup Liveblocks Go to https://liveblocks.io/ and sign up. You will be taken to the dashboard, where two projects will be created for you. Click on “Project development,” then go to the “API Keys” section on the left panel. Copy the Public key and secret key. Now add the NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY and NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY to .env.development.local NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY=pk_dev_xxxxxx_xxxxxxxx NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY=sk_dev_xxxxxx_xxxxxxxx Later, we will use this public API key in LiveblocksProvider Let’s install the Liveblocks dependencies and also Tiptap (for building a rich text editor). npm install @liveblocks/client @liveblocks/node @liveblocks/react @liveblocks/yjs yjs @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror Create a new file lib/liveblocks.ts import { Liveblocks } from '@liveblocks/node'; export const LIVEBLOCKS_CLIENT = new Liveblocks({ secret: process.env.NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY!, }); Create a new file liveblocks.config.ts to define types. declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number } | null }; UserMeta: { id: string; info: { name: string; color: string; }; }; } } export {}; For now, this completes the Liveblocks setup. Let’s move on to the next section to set up the most important component of the application permit.io. Setup Permit.io To get started, you’ll first need to create an account. Sign up on Permit.io and create a new workspace for your team or organization. This will allow you to invite team members and use all the collaboration features Permit.io offers. After creating a workspace, create a project and name it “Collaborative Docs.” Then, create an environment called Development. Now click on “open dashboard,” then go to the “policy” section in the left panel, and select the “resources” tab. Click on “Add Resource,” and a form panel will open. Provide the following details and save. Name: Document Key: document Actions: create, delete, read, update ReBAC Roles: owner, editor, viewer We want to set up roles for document resources so that every instance has its own roles like owner, viewer, and editor. Navigate to the “Policy Editor” tab and set up the permissions for the roles we created. Owner: should have all permissions Editor: should have update and read permissions Viewer: should have read-only permission Super easy, right? With just a few steps, our resources and permissions are set. That’s the benefit of using Permit.io — it makes everything easy. Let’s install the permit.io dependencies and set up the client so we can interact with permit.io programmatically. It supports SDKs in many languages, and we will use the Node.js SDK. npm install permitio The SDK needs an API key. You can get it by going to Settings → API Keys. Copy the secret and add it as an environment variable NEXT_PUBLIC_PERMIT_API_KEY in .env.development.local. NEXT_PUBLIC_PERMIT_API_KEY=permit_key_xxxxxxxxxxxxx To use ReBAC functionality with the Permit.io SDK, we will install the PDP (Policy-Decision-Point) via Docker, as Permit.io currently recommends it. For zero latency, great performance, high availability, and improved security, they are working on making it available directly on the cloud soon. You can install Docker by visiting https://docs.docker.com/get-started/get-docker/ . It’s very easy just install it and start Docker Desktop. Create a docker-compose.yml file in the root directory. version: '3' services: pdp-service: image: permitio/pdp-v2:latest ports: - "7766:7000" environment: - PDP_API_KEY=permit_key_xxxxxxxxx - PDP_DEBUG=True stdin_open: true tty: true Then run the command to start the PDP service. docker compose up -d Once it’s running, you can visit http://localhost:7766 and you should see the response below. { "status": "ok" } Great, now let’s set up the Permit.io SDK by creating lib/permitio.ts. import { Permit } from 'permitio'; const pdpUrl = process.env.PDP_URL || 'http://localhost:7766'; const apiKey = process.env.NEXT_PUBLIC_PERMIT_API_KEY!; export const PERMITIO_SDK = new Permit({ token: apiKey, pdp: pdpUrl, }); Awesome, we have set up all the components of our application. In the next section, we will implement authentication for sign-up, login, and logout with Appwrite, as well as authorization using Permit ReBAC with their SDK. Implementing Authentication and Authorization We will manage some global states in our application, so let’s install zustand for state management. npm install zustand After installing Zustand, let’s create a store/authStore.ts to manage authentication states easily. import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models } from 'appwrite'; const { account } = APPWRITE_CLIENT; const ERROR_TIMEOUT = 8000; interface AuthState { user: Models.User<Models.Preferences> | null; session: Models.Session | null; isLoading: boolean; error: string | null; login: (email: string, password: string) => Promise<void>; register: (email: string, password: string, name: string) => Promise<void>; logout: () => Promise<void>; checkAuth: () => Promise<void>; } export const useAuthStore = create<AuthState>()( persist( (set) => ({ user: null, isLoading: true, error: null, session: null, login: async (email, password) => { try { set({ isLoading: true, error: null }); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, register: async (email, password, name) => { try { set({ isLoading: true, error: null }); await account.create(ID.unique(), email, password, name); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, logout: async () => { try { set({ isLoading: true, error: null }); await account.deleteSession('current'); set({ user: null, isLoading: false, session: null }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, checkAuth: async () => { try { set({ isLoading: true }); const user = await account.get(); const session = await account.getSession('current'); set({ user, session }); } catch (error) { console.error("Couldn't get user", (error as Error).message); } finally { set({ isLoading: false }); } }, }), { name: 'collabdocs-session', // name of item in the storage (must be unique) partialize: (state) => ({ session: state.session, }), } ) ); Here we are using Zustand create to make the useAuthStore hook. It has methods for login, registration, logout, and checking authentication status while managing user and session data. The session state is saved in local storage to keep session information even after the page reloads. Let’s create our first component components/navbar.tsx and add the following code. It’s a simple component with a brand name and a link or logout button based on the user's authentication status. 'use client'; import { useAuthStore } from '@/store/authStore'; import { FileText } from 'lucide-react'; import Link from 'next/link'; import { Button } from './ui/button'; export default function Navbar() { const { user, logout } = useAuthStore(); return ( <header className="px-4 lg:px-6 h-14 flex items-center border-b shadow-md"> <Link className="flex items-center justify-center" href="/"> <FileText className="h-6 w-6 mr-2" /> <span className="font-bold">CollabDocs</span> </Link> <nav className="ml-auto flex gap-4 sm:gap-6"> {user ? ( <div className="flex gap-4 items-center"> <span> {user.name} ({user.email}) </span> <Button variant="outline" onClick={logout}> Logout </Button> </div> ) : ( <Link className="text-sm font-medium hover:underline underline-offset-4" href="/#features" > Features </Link> )} </nav> </header> ); } Since we need to keep the authentication state and only let logged-in users access the dashboard page (which we will create soon), let’s create a components/auth-wrapper.tsx component. 'use client'; import { useAuthStore } from '@/store/authStore'; import { LoaderIcon } from 'lucide-react'; import { redirect, usePathname } from 'next/navigation'; import { useEffect } from 'react'; export default function AuthWrapper({ children, }: { children: React.ReactNode; }) { const pathname = usePathname(); const { user, isLoading, checkAuth } = useAuthStore(); useEffect(() => { checkAuth(); }, [checkAuth]); if (isLoading) { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); } if (!user && pathname.startsWith('/dashboard')) { redirect('/login'); } return children; } AuthWrapper will take children as a prop and check if the user is authenticated. If they are, it will render the children. If not, and they are trying to access the dashboard page, it will redirect them to the login page. Then update the root layout component with the following code: import Navbar from '@/components/navbar'; import AuthWrapper from '@/components/auth-wrapper'; import { Toaster } from '@/components/ui/toaster'; ... // existing code export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <AuthWrapper> <Navbar /> <main>{children}</main> </AuthWrapper> <Toaster /> </body> </html> ); } Cool, the layout setup is done. Now let’s create a landing page for our application. Create components/landing-page.tsx and add the following code. 'use client'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { FileText, Users, Share2, ShieldCheck } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; export default function LandingPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col min-h-screen"> <main className="flex-1"> <section className="w-full py-12 md:py-24 lg:py-32 xl:py-48"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center space-y-4 text-center"> <div className="space-y-2"> <h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none"> Collaborate on Documents in Real-Time </h1> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Create, edit, and share documents with ease. Powerful collaboration tools for teams of all sizes. </p> </div> {user ? ( <Button asChild> <Link href="/dashboard">Dashboard</Link> </Button> ) : ( <div className="space-x-4"> <Button asChild> <Link href="/signup">Get Started</Link> </Button> <Button variant="outline" asChild> <Link href="/login">Log In</Link> </Button> </div> )} </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-800" id="features" > <div className="px-4 md:px-6"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl text-center mb-8"> Key Features </h2> <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3"> <div className="flex flex-col items-center text-center"> <FileText className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Rich Text Editing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Create beautiful documents with our powerful rich text editor. </p> </div> <div className="flex flex-col items-center text-center"> <Users className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Real-Time Collaboration</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Work together in real-time with your team members. </p> </div> <div className="flex flex-col items-center text-center"> <Share2 className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Easy Sharing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Share your documents with others quickly and securely. </p> </div> <div className="flex flex-col items-center text-center"> <ShieldCheck className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Role-Based Access</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Control access with Owner, Editor, and Viewer roles. </p> </div> </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center justify-center space-y-4 text-center"> <div className="space-y-2"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl"> Start Collaborating Today </h2> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Join thousands of teams already using CollabDocs to streamline their document workflows. </p> </div> <Button asChild size="lg"> <Link href={user ? '/dashboard' : '/signup'}> Sign Up for Free </Link> </Button> </div> </div> </section> </main> <footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t"> <p className="text-xs text-gray-500 dark:text-gray-400"> © {new Date().getFullYear()} CollabDocs. All rights reserved. </p> <nav className="sm:ml-auto flex gap-4 sm:gap-6"> <Link className="text-xs hover:underline underline-offset-4" href="#"> Terms of Service </Link> <Link className="text-xs hover:underline underline-offset-4" href="#"> Privacy </Link> </nav> </footer> </div> ); } Then update the app/page.tsx with the code below. import LandingPage from '@/components/landing-page'; export default function Home() { return <LandingPage />; } Start the development server and visit localhost:3000. You will see a nice landing page for our application. Alright, the landing page is done, now we will be creating three pages, login, signup, and dashboard. Create components/login.tsx and components/register.tsx and add the following code. // login.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Login() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const { login, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await login(email, password); if (!error) { router.push(nextPath ?? '/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Login</CardTitle> <CardDescription> Enter your email and password to login. </CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Login</Button> <Link href={nextPath ? `/signup?next=${nextPath}` : '/signup'}> Sign up </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } Here we have a simple form with email and password fields. When the user submits, we call the login method from the useAuthStore hook. If the login is successful, we redirect the user to the dashboard page. If there is an error, it will be shown using the error state. // register.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Register() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const { register, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await register(email, password, name); if (!error) { router.push('/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Register</CardTitle> <CardDescription>Create a new account.</CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="name">Name</Label> <Input id="name" placeholder="Enter your name" value={name} onChange={(e) => setName(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Register</Button> <Link href={nextPath ? `/login?next=${nextPath}` : '/login'}> Login </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } The Register component is simple, with fields for name, email, and password. When the user submits, we call the register method from the useAuthStore hook. If registration is successful, the user is redirected to the dashboard page. If there's an error, it will be shown using the error state. Create the login and signup pages and display the respective components. // app/login/page.tsx import Login from '@/components/login'; export default function LoginPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Login /> </div> ); } // app/signup/page.tsx import Register from '@/components/register'; export default function SignUpPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Register />; </div> ); } Create a basic dashboard page for now, and we will update it later. // app/dashboard/page.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; export default function DashboardPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col items-center justify-center min-h-screen"> Welcome to the dashboard, {user?.name}! </div> ); } Now start the dev server using the npm run dev command. Try visiting the /dashboard page, and you will be redirected to the login page. This happens because we added AuthWrapper in the root layout, which checks user authentication and redirects accordingly. Try to register with a valid name, email, and password, and you will be redirected to the dashboard page. Authentication is set up and working as expected. Now, let’s add server actions for authorization using the Permit.io SDK. As authorization code should run on the server side, let’s createapp/actions.tsand add the following initial code // app/actions.ts 'use server'; import { PERMITIO_SDK } from '@/lib/permitio'; // Permit.io actions interface User { email: string; key: string; } interface ResourceInstance { key: string; resource: string; } interface ResourceInstanceRole { user: string; role: string; resource_instance: string; } export type PermissionType = 'read' | 'create' | 'delete' | 'update'; interface ResourcePermission { user: string; resource_instance: string; permissions: PermissionType[]; } When a new user signs up in our application, we need to sync that user with Permit.io. Let’s add our first action, syncUserWithPermit. // app/actions.ts ... /** * * @param user `{email: string, key: string}` */ export async function syncUserWithPermit(user: User) { try { const syncedUser = await PERMITIO_SDK.api.syncUser(user); console.log('User synced with Permit.io', syncedUser.email); } catch (error) { console.error(error); } } Each user’s email ID will be unique, so we will use it for both the email and key attributes. Now let’s use syncUserWithPermit in the register method of our AuthStore. This way, when a user is created, they are also synced with Permit.io. // store/authStore.ts ... register: async (email, password, name) => { ... // sync user with Permit.io await syncUserWithPermit({ email: user.email, key: user.email }); } Next, add three more actions that we will use later. // app/actions.ts ... async function getPermitioUser(key: string) { try { const user = await PERMITIO_SDK.api.users.getByKey(key); return user; } catch (error) { console.error(error); return null; } } /** * * @param resourceInstance `{key: string, resource: string}` * @returns createdInstance */ export async function createResourceInstance( resourceInstance: ResourceInstance ) { console.log('Creating a resource instance...'); try { const createdInstance = await PERMITIO_SDK.api.resourceInstances.create({ key: resourceInstance.key, tenant: 'default', resource: resourceInstance.resource, }); console.log(`Resource instance created: ${createdInstance.key}`); return createdInstance; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourceInstanceRole `{user: string, role: string, resource_instance: string}` * @returns assignedRole */ export async function assignResourceInstanceRoleToUser( resourceInstanceRole: ResourceInstanceRole ) { try { const user = await getPermitioUser(resourceInstanceRole.user); if (!user) { await syncUserWithPermit({ email: resourceInstanceRole.user, key: resourceInstanceRole.user, }); } const assignedRole = await PERMITIO_SDK.api.roleAssignments.assign({ user: resourceInstanceRole.user, role: resourceInstanceRole.role, resource_instance: resourceInstanceRole.resource_instance, tenant: 'default', }); console.log(`Role assigned: ${assignedRole.role} to ${assignedRole.user}`); return assignedRole; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourcePermission `{user: string, resource_instance: string, permission: string}` * @returns permitted */ export async function getResourcePermissions( resourcePermission: ResourcePermission ) { try { const permissions = resourcePermission.permissions; const permissionMap: Record<PermissionType, boolean> = { read: false, create: false, delete: false, update: false, }; for await (const permission of permissions) { permissionMap[permission] = await PERMITIO_SDK.check( resourcePermission.user, permission, resourcePermission.resource_instance ); } return permissionMap; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return { read: false, create: false, delete: false, update: false, }; } } createResourceInstance: We use this when a user creates a document. It creates the document instance with a unique key in Permit.io. assignResourceInstanceRoleToUser: This assigns the right role (owner, editor, or viewer) to the user for a specific resource instance. getResourcePermissions: This is a straightforward but effective method we use to obtain the resource permissions. With just a few lines of code, we have the authorization logic. That’s the power of Permit.io. Great, now let’s create our dashboard page so users can view their documents, create new ones, and search through them. // components/loader.tsx import { LoaderIcon } from 'lucide-react'; import React from 'react'; const Loader = () => { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); }; export default Loader; // app/dashboard/page.tsx 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { FileText, Plus, Search } from 'lucide-react'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models, Query } from 'appwrite'; import { assignResourceInstanceRoleToUser, createResourceInstance, } from '../actions'; import { useAuthStore } from '@/store/authStore'; import Loader from '@/components/loader'; import { toast } from '@/hooks/use-toast'; export interface Document extends Models.Document { roomId: string; title: string; storageData: string; created_by: string; } export default function Dashboard() { const [documents, setDocuments] = useState<Document[]>([]); const [searchTerm, setSearchTerm] = useState(''); const [newDocTitle, setNewDocTitle] = useState(''); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const { user } = useAuthStore(); const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(searchTerm.toLowerCase()) ); const fetchDocuments = async () => { setIsLoading(true); try { const response = await APPWRITE_CLIENT.databases.listDocuments<Document>( 'database_id', 'collection_id', [Query.contains('created_by', user?.$id ?? '')] ); setDocuments(response.documents); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to fetch documents. Please try again.', variant: 'destructive', }); } finally { setIsLoading(false); } }; const handleCreateDocument = async () => { setIsCreating(true); try { const documentId = ID.unique(); const response = await APPWRITE_CLIENT.databases.createDocument<Document>( 'database_id', 'collection_id', documentId, { title: newDocTitle.trim(), roomId: documentId, created_by: user?.$id ?? '', } ); const createdInstance = await createResourceInstance({ key: documentId, resource: 'document', }); if (!createdInstance) { throw new Error('Failed to create resource instance'); } const assignedRole = await assignResourceInstanceRoleToUser({ resource_instance: `document:${createdInstance.key}`, role: 'owner', user: user?.email ?? '', }); if (!assignedRole) { throw new Error('Failed to assign role'); } setDocuments((prev) => [...prev, response]); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to create document. Please try again.', variant: 'destructive', }); } finally { setIsCreating(false); setIsDialogOpen(false); } }; useEffect(() => { fetchDocuments(); }, []); if (isLoading) { return <Loader />; } return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">My Documents</h1> <div className="flex justify-between items-center mb-6"> <div className="relative w-64"> <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" /> <Input type="text" placeholder="Search documents" className="pl-8" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> </div> <Dialog modal open={isDialogOpen} onOpenChange={(value) => setIsDialogOpen(isCreating ? isCreating : value) } > <DialogTrigger asChild> <Button> <Plus className="mr-2 h-4 w-4" /> New Document </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create New Document</DialogTitle> <DialogDescription> Enter a title for your new document. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="name" className="text-right"> Title </Label> <Input id="name" value={newDocTitle} onChange={(e) => setNewDocTitle(e.target.value)} className="col-span-3" /> </div> </div> <DialogFooter> <Button onClick={handleCreateDocument}>Create</Button> </DialogFooter> </DialogContent> </Dialog> </div> {documents.length === 0 && ( <p className="text-center text-gray-500"> You don&apos;t have any documents yet. Click on the{' '} <strong> New Document </strong> button to create one. </p> )} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {filteredDocuments.map((doc) => ( <Card key={doc.$id}> <CardHeader> <CardTitle>{doc.title}</CardTitle> <CardDescription> Last edited: {new Date(doc.$updatedAt).toDateString()} </CardDescription> </CardHeader> <CardContent> <FileText className="h-16 w-16 text-gray-400" /> </CardContent> <CardFooter> <Button asChild> <Link href={`/document/${doc.$id}`}>Open</Link> </Button> </CardFooter> </Card> ))} </div> </div> ); } Here we have the fetchDocuments method that gets the documents created by the user. We also have the handleCreateDocument method, which creates a document and then sets up the document resource in Permit.io assigning the creator the role of "owner". Create a document titled “What is Relationship-Based Access Control (ReBAC)?” and save it. Now let’s create a document page and its components. The ShareDocument component allows users to share documents with other users. It takes two props: documentId and permission. If permission is true, the share button will appear; otherwise, nothing will appear. // components/share-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Share2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { assignResourceInstanceRoleToUser } from '@/app/actions'; interface ShareDocumentProps { documentId: string; permission: boolean; } export function ShareDocument({ documentId, permission }: ShareDocumentProps) { const [email, setEmail] = useState(''); const [role, setRole] = useState('viewer'); const [isOpen, setIsOpen] = useState(false); const [isSharing, setIsSharing] = useState(false); const handleShare = async () => { if (!email) { toast({ title: 'Error', description: 'Please enter an email address.', variant: 'destructive', }); return; } setIsSharing(true); try { await assignResourceInstanceRoleToUser({ user: email, role, resource_instance: `document:${documentId}`, }); await navigator.clipboard.writeText(window.location.href); toast({ title: 'Success', description: `Document shared successfully. Link copied to clipboard.`, }); setIsOpen(false); setEmail(''); setRole('viewer'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to share the document. Please try again.', variant: 'destructive', }); } finally { setIsSharing(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="outline"> <Share2 className="mr-2 h-4 w-4" /> Share </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Share Document</DialogTitle> <DialogDescription> Enter the email address of the person you want to share this document with and select their role. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="email" className="text-right"> Email </Label> <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="col-span-3" placeholder="user@example.com" /> </div> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="role" className="text-right"> Role </Label> <Select value={role} onValueChange={setRole}> <SelectTrigger className="col-span-3"> <SelectValue placeholder="Select a role" /> </SelectTrigger> <SelectContent> <SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="editor">Editor</SelectItem> <SelectItem value="owner">Owner</SelectItem> </SelectContent> </Select> </div> </div> <DialogFooter> <Button onClick={handleShare} disabled={isSharing}> {isSharing ? 'Sharing...' : 'Share'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } Next is the DeleteDocument component, which allows the user to delete the document if they have permission. If they don't, nothing will be displayed. It takes two props: documentId and permission. // components/delete-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Trash2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useRouter } from 'next/navigation'; interface DeleteDocumentProps { documentId: string; permission: boolean; } export function DeleteDocument({ documentId, permission, }: DeleteDocumentProps) { const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { setIsDeleting(true); try { await APPWRITE_CLIENT.databases.deleteDocument( 'database_id', 'collection_id', documentId ); toast({ title: 'Success', description: 'Document deleted successfully.', }); setIsOpen(false); router.push('/dashboard'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to delete the document. Please try again.', variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="destructive"> <Trash2 className="mr-2 h-4 w-4" /> Delete </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Delete Document</DialogTitle> <DialogDescription> Are you sure you want to delete this document? This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={() => setIsOpen(false)}> Cancel </Button> <Button variant="destructive" onClick={handleDelete} disabled={isDeleting} > {isDeleting ? 'Deleting...' : 'Delete'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } Right now, it’s open to everyone, meaning anyone can access it, and it doesn’t show the collaborative editor with the content. In the next section, we will create the collaborative editor (using liveblock) and set up permissions using the Permit.io server action we wrote earlier. Building the Collaborative Document Editor Great job following along so far! Now comes the exciting part: building the collaborative editor. This will allow different users to work together in real time. Each user will have different permissions, so they can read, update, or delete the document based on their access rights. Let’s begin by adding some CSS to our global.css file. ... /* Give a remote user a caret */ .collaboration-cursor__caret { border-left: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d; margin-left: -1px; margin-right: -1px; pointer-events: none; position: relative; word-break: normal; } /* Render the username above the caret */ .collaboration-cursor__label { font-style: normal; font-weight: 600; left: -1px; line-height: normal; position: absolute; user-select: none; white-space: nowrap; font-size: 14px; color: #fff; top: -1.4em; border-radius: 6px; border-bottom-left-radius: 0; padding: 2px 6px; pointer-events: none; } Then, create a few components to make our editor easier to use. The Avatars component will display the avatars of online users who are collaborating on the same document. It uses two hooks from Liveblocks: useOthers and useSelf. // components/editor/user-avatars.tsx import { useOthers, useSelf } from '@liveblocks/react/suspense'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../ui/tooltip'; export function Avatars() { const users = useOthers(); const currentUser = useSelf(); return ( <div className="flex py-[0.75rem]"> {users.map(({ connectionId, info }) => { return ( <Avatar key={connectionId} name={info.name} color={info.color} /> ); })} {currentUser && ( <div className="relative ml-8 first:ml-0"> <Avatar color={currentUser.info.color} name={currentUser.info.name} /> </div> )} </div> ); } export function Avatar({ name, color }: { name: string; color: string }) { return ( <TooltipProvider> <Tooltip> <TooltipTrigger> <div className="flex place-content-center relative border-4 border-white rounded-full w-[42px] h-[42px] bg-[#9ca3af] ml-[-0.75rem]"> <div className="w-full h-full rounded-full flex items-center justify-center text-white" style={{ background: color }} > {name.slice(0, 2).toUpperCase()} </div> </div> </TooltipTrigger> <TooltipContent>{name}</TooltipContent> </Tooltip> </TooltipProvider> ); } Next, we will need a toolbar for showing different formatting options in our editor. it will have formatting options like bold, italic, strike, list and so on. // components/editor/icons.tsx import React from 'react'; export const BoldIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M18.25 25H9V7H17.5C18.5022 7.00006 19.4834 7.28695 20.3277 7.82679C21.172 8.36662 21.8442 9.13684 22.2649 10.0465C22.6855 10.9561 22.837 11.9671 22.7015 12.96C22.5659 13.953 22.149 14.8864 21.5 15.65C22.3477 16.328 22.9645 17.252 23.2653 18.295C23.5662 19.3379 23.5364 20.4485 23.18 21.4738C22.8236 22.4991 22.1581 23.3887 21.2753 24.0202C20.3924 24.6517 19.3355 24.994 18.25 25ZM12 22H18.23C18.5255 22 18.8181 21.9418 19.091 21.8287C19.364 21.7157 19.6121 21.5499 19.821 21.341C20.0299 21.1321 20.1957 20.884 20.3087 20.611C20.4218 20.3381 20.48 20.0455 20.48 19.75C20.48 19.4545 20.4218 19.1619 20.3087 18.889C20.1957 18.616 20.0299 18.3679 19.821 18.159C19.6121 17.9501 19.364 17.7843 19.091 17.6713C18.8181 17.5582 18.5255 17.5 18.23 17.5H12V22ZM12 14.5H17.5C17.7955 14.5 18.0881 14.4418 18.361 14.3287C18.634 14.2157 18.8821 14.0499 19.091 13.841C19.2999 13.6321 19.4657 13.384 19.5787 13.111C19.6918 12.8381 19.75 12.5455 19.75 12.25C19.75 11.9545 19.6918 11.6619 19.5787 11.389C19.4657 11.116 19.2999 10.8679 19.091 10.659C18.8821 10.4501 18.634 10.2843 18.361 10.1713C18.0881 10.0582 17.7955 10 17.5 10H12V14.5Z" fill="currentColor" /> </svg> ); export const ItalicIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M25 9V7H12V9H17.14L12.77 23H7V25H20V23H14.86L19.23 9H25Z" fill="currentColor" /> </svg> ); export const StrikethroughIcon = () => ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M17.1538 14C17.3846 14.5161 17.5 15.0893 17.5 15.7196C17.5 17.0625 16.9762 18.1116 15.9286 18.867C14.8809 19.6223 13.4335 20 11.5862 20C9.94674 20 8.32335 19.6185 6.71592 18.8555V16.6009C8.23538 17.4783 9.7908 17.917 11.3822 17.917C13.9333 17.917 15.2128 17.1846 15.2208 15.7196C15.2208 15.0939 15.0049 14.5598 14.5731 14.1173C14.5339 14.0772 14.4939 14.0381 14.4531 14H3V12H21V14H17.1538ZM13.076 11H7.62908C7.4566 10.8433 7.29616 10.6692 7.14776 10.4778C6.71592 9.92084 6.5 9.24559 6.5 8.45207C6.5 7.21602 6.96583 6.165 7.89749 5.299C8.82916 4.43299 10.2706 4 12.2219 4C13.6934 4 15.1009 4.32808 16.4444 4.98426V7.13591C15.2448 6.44921 13.9293 6.10587 12.4978 6.10587C10.0187 6.10587 8.77917 6.88793 8.77917 8.45207C8.77917 8.87172 8.99709 9.23796 9.43293 9.55079C9.86878 9.86362 10.4066 10.1135 11.0463 10.3004C11.6665 10.4816 12.3431 10.7148 13.076 11H13.076Z" fill="currentColor" ></path> </svg> ); export const BlockQuoteIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M11.5 13.375H7.81875C7.93491 12.6015 8.21112 11.8607 8.62974 11.1999C9.04837 10.5391 9.6002 9.97295 10.25 9.5375L11.3688 8.7875L10.6812 7.75L9.5625 8.5C8.6208 9.12755 7.84857 9.97785 7.31433 10.9755C6.7801 11.9731 6.50038 13.0871 6.5 14.2188V18.375C6.5 18.7065 6.6317 19.0245 6.86612 19.2589C7.10054 19.4933 7.41848 19.625 7.75 19.625H11.5C11.8315 19.625 12.1495 19.4933 12.3839 19.2589C12.6183 19.0245 12.75 18.7065 12.75 18.375V14.625C12.75 14.2935 12.6183 13.9755 12.3839 13.7411C12.1495 13.5067 11.8315 13.375 11.5 13.375ZM20.25 13.375H16.5688C16.6849 12.6015 16.9611 11.8607 17.3797 11.1999C17.7984 10.5391 18.3502 9.97295 19 9.5375L20.1188 8.7875L19.4375 7.75L18.3125 8.5C17.3708 9.12755 16.5986 9.97785 16.0643 10.9755C15.5301 11.9731 15.2504 13.0871 15.25 14.2188V18.375C15.25 18.7065 15.3817 19.0245 15.6161 19.2589C15.8505 19.4933 16.1685 19.625 16.5 19.625H20.25C20.5815 19.625 20.8995 19.4933 21.1339 19.2589C21.3683 19.0245 21.5 18.7065 21.5 18.375V14.625C21.5 14.2935 21.3683 13.9755 21.1339 13.7411C20.8995 13.5067 20.5815 13.375 20.25 13.375Z" fill="currentColor" /> </svg> ); export const HorizontalLineIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <rect x="6.5" y="13.375" width="15" height="1.25" fill="currentColor" /> </svg> ); export const OrderedListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9ZM9 11.5V6.5H7.75V7.125H6.5V8.375H7.75V11.5H6.5V12.75H10.25V11.5H9ZM10.25 21.5H6.5V19C6.5 18.6685 6.6317 18.3505 6.86612 18.1161C7.10054 17.8817 7.41848 17.75 7.75 17.75H9V16.5H6.5V15.25H9C9.33152 15.25 9.64946 15.3817 9.88388 15.6161C10.1183 15.8505 10.25 16.1685 10.25 16.5V17.75C10.25 18.0815 10.1183 18.3995 9.88388 18.6339C9.64946 18.8683 9.33152 19 9 19H7.75V20.25H10.25V21.5Z" fill="currentColor" /> </svg> ); export const BulletListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M8.375 11.5C9.41053 11.5 10.25 10.6605 10.25 9.625C10.25 8.58947 9.41053 7.75 8.375 7.75C7.33947 7.75 6.5 8.58947 6.5 9.625C6.5 10.6605 7.33947 11.5 8.375 11.5Z" fill="currentColor" /> <path d="M8.375 20.25C9.41053 20.25 10.25 19.4105 10.25 18.375C10.25 17.3395 9.41053 16.5 8.375 16.5C7.33947 16.5 6.5 17.3395 6.5 18.375C6.5 19.4105 7.33947 20.25 8.375 20.25Z" fill="currentColor" /> <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9Z" fill="currentColor" /> </svg> ); // components/editor/toolbar.tsx import { Editor } from '@tiptap/react'; import { BoldIcon, ItalicIcon, StrikethroughIcon, BlockQuoteIcon, HorizontalLineIcon, BulletListIcon, OrderedListIcon, } from './icons'; import { cn } from '@/lib/utils'; type Props = { editor: Editor | null; }; type ButtonProps = { editor: Editor; isActive: boolean; ariaLabel: string; Icon: React.FC; onClick: () => void; }; const ToolbarButton = ({ isActive, ariaLabel, Icon, onClick }: ButtonProps) => ( <button className={cn( 'flex items-center justify-center w-8 h-8 border border-gray-200 rounded', { 'bg-gray-100': isActive, } )} onClick={onClick} data-active={isActive ? 'is-active' : undefined} aria-label={ariaLabel} > <Icon /> </button> ); export function Toolbar({ editor }: Props) { if (!editor) { return null; } return ( <div className="flex gap-4"> <ToolbarButton editor={editor} isActive={editor.isActive('bold')} ariaLabel="bold" Icon={BoldIcon} onClick={() => editor.chain().focus().toggleBold().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('italic')} ariaLabel="italic" Icon={ItalicIcon} onClick={() => editor.chain().focus().toggleItalic().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('strike')} ariaLabel="strikethrough" Icon={StrikethroughIcon} onClick={() => editor.chain().focus().toggleStrike().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('blockquote')} ariaLabel="blockquote" Icon={BlockQuoteIcon} onClick={() => editor.chain().focus().toggleBlockquote().run()} /> <ToolbarButton editor={editor} isActive={false} ariaLabel="horizontal-line" Icon={HorizontalLineIcon} onClick={() => editor.chain().focus().setHorizontalRule().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('bulletList')} ariaLabel="bullet-list" Icon={BulletListIcon} onClick={() => editor.chain().focus().toggleBulletList().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('orderedList')} ariaLabel="number-list" Icon={OrderedListIcon} onClick={() => editor.chain().focus().toggleOrderedList().run()} /> </div> ); } Now, let’s use Tiptap and Liveblocks together to create our collaborative editor component. Liveblocks uses rooms to let users work together. Each room can have its own permissions and details. For real-time collaboration, we will use the useRoom hook and LiveblocksYjsProvider with Tiptap's Collaboration and CollaborationCursor extensions. It takes one prop, isReadOnly, to decide if users can edit the document or not. // components/editor/collaborative-editor.tsx 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import * as Y from 'yjs'; import { LiveblocksYjsProvider } from '@liveblocks/yjs'; import { useRoom, useSelf } from '@liveblocks/react/suspense'; import { useEffect, useState } from 'react'; import { Toolbar } from './toolbar'; import { Avatars } from './user-avatars'; export function CollaborativeEditor({ isReadOnly }: { isReadOnly: boolean }) { const room = useRoom(); const [doc, setDoc] = useState<Y.Doc>(); const [provider, setProvider] = useState<LiveblocksYjsProvider>(); useEffect(() => { const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc); setDoc(yDoc); setProvider(yProvider); return () => { yDoc?.destroy(); yProvider?.destroy(); }; }, [room]); if (!doc || !provider) { return null; } return <TiptapEditor isReadOnly={isReadOnly} doc={doc} provider={provider} />; } function TiptapEditor({ doc, provider, isReadOnly, }: { doc: Y.Doc; provider: LiveblocksYjsProvider; isReadOnly: boolean; }) { const userInfo = useSelf((me) => me.info); const editor = useEditor({ editorProps: { attributes: { class: 'flex-grow w-full h-full pt-4 focus:outline-none', }, editable: () => !isReadOnly, }, extensions: [ StarterKit.configure({ history: false, }), Collaboration.configure({ document: doc, }), CollaborationCursor.configure({ provider: provider, user: userInfo, }), ], }); return ( <div className="flex flex-col bg-white w-full h-full"> <div className="flex justify-between items-center"> <Toolbar editor={editor} /> <Avatars /> </div> <EditorContent readOnly={isReadOnly} editor={editor} className="relative h-full" /> </div> ); } As we discussed, Liveblocks uses rooms for collaboration, so we need a way to create a Liveblock session with permissions for the active room. This way, each user in the room can have their unique identity and permissions. Create app/api/liveblock-session/route.ts and add the following code. import { LIVEBLOCKS_CLIENT } from '@/lib/liveblocks'; import { NextRequest } from 'next/server'; function generateRandomHexColor() { const randomColor = Math.floor(Math.random() * 16777215).toString(16); return `#${randomColor.padStart(6, '0')}`; } export async function POST(request: NextRequest) { const { user, roomId, permissions } = await request.json(); const allowedPermission: ('room:read' | 'room:write')[] = []; const session = LIVEBLOCKS_CLIENT.prepareSession(user.$id, { userInfo: { name: user.name, color: generateRandomHexColor(), }, }); if (permissions.read) { allowedPermission.push('room:read'); } if (permissions.update) { allowedPermission.push('room:write'); } session.allow(roomId!, allowedPermission); const { body, status } = await session.authorize(); return new Response(body, { status }); } Here, we are creating a POST route to set up a liveblock session. In the request, we get user details, the active roomId, and permissions (from Permit.io, which we’ll discuss soon). We use the Liveblocks client’s prepareSession method to create the session with the user's unique ID and some extra information (used to display the live cursor and user avatars in the editor). We then check if the user has read or update permissions and add the appropriate room permission, either room:read or room:write. Finally, we call the session.allow method with the roomId and permissions list, and then authorize it to generate a unique token. For userInfo, we can only pass the name and color attributes because we have defined only those attributes in liveblocks.config.ts. Alright, now let’s create a LiveblocksWrapper component and use the endpoint we made. // components/editor/liveblocks-wrapper.tsx 'use client'; import { ClientSideSuspense, LiveblocksProvider, RoomProvider, } from '@liveblocks/react/suspense'; import Loader from '../loader'; import { PermissionType } from '@/app/actions'; import { useAuthStore } from '@/store/authStore'; interface LiveblocksWrapperProps { children: React.ReactNode; roomId: string; permissions: Record<PermissionType, boolean>; } export default function LiveblocksWrapper({ children, roomId, permissions, }: Readonly<LiveblocksWrapperProps>) { const { user } = useAuthStore(); return ( <LiveblocksProvider authEndpoint={async (room) => { const response = await fetch('/api/liveblock-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user: user, roomId: roomId, room, permissions, }), }); return await response.json(); }} > <RoomProvider id={roomId} initialPresence={{ cursor: null, }} > <ClientSideSuspense fallback={<Loader />}> {children} </ClientSideSuspense> </RoomProvider> </LiveblocksProvider> ); } Here, we use the LiveblocksProvider from the Liveblocks package, which takes authEndpoint as a prop. We call the '/api/liveblock-session' endpoint with the user, roomId, and permissions. Next, we use RoomProvider to create a separate space for collaboration. It takes two props: id (roomId) and initialPresence (to show the active user's cursor in the editor). ClientSideSuspense is used to display a fallback until the room is ready. Great, we have the LiveblocksWrapper ready. Now, let’s use it on the document page. But before that, let’s add an authorization check on our document page by getting the permissions using permit.io server actions. // app/document/[id]/page.tsx ... import { getResourcePermissions, PermissionType } from "@/app/actions"; import { Button } from "@/components/ui/button"; import { useAuthStore } from "@/store/authStore"; import { useRouter } from "next/navigation"; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); ... const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ["read", "update", "delete"], resource_instance: `document:${params.id}`, user: user?.email ?? "", }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); ... if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? "/dashboard" : `/login?next=/document/${params.id}`, ) } > {user ? "Dashboard" : "Login"} </Button> </section> ); } .... } Here, we are updating the document page by adding a new method called fetchPermissions. This method runs first to check if the current user has permission, specifically read permission, before fetching the document content. If the user doesn't have permission, it shows a message saying they can't view the document. The final code for the document page will include a permission check, and the content will be wrapped with the LiveBlocks provider. 'use client'; import { getResourcePermissions, PermissionType } from '@/app/actions'; import { Document } from '@/app/dashboard/page'; import { DeleteDocument } from '@/components/delete-document'; import { CollaborativeEditor } from '@/components/editor/collaborative-editor'; import LiveblocksWrapper from '@/components/editor/liveblocks-wrapper'; import Loader from '@/components/loader'; import { ShareDocument } from '@/components/share-document'; import { Button } from '@/components/ui/button'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useAuthStore } from '@/store/authStore'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); const [isLoading, setIsLoading] = useState(true); const [document, setDocument] = useState<Document | null>(null); const fetchDocument = async () => { try { const document = await APPWRITE_CLIENT.databases.getDocument<Document>( '66fad2d0001b08997cb9', '66fad37e0033c987cf4d', params.id ); setDocument(document); } catch (error) { console.error(error); } finally { setIsLoading(false); } }; const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ['read', 'update', 'delete'], resource_instance: `document:${params.id}`, user: user?.email ?? '', }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); if (isLoading) { return <Loader />; } if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? '/dashboard' : `/login?next=/document/${params.id}` ) } > {user ? 'Dashboard' : 'Login'} </Button> </section> ); } if (!document) { return ( <section className="h-screen flex justify-center items-center"> <p>Document not found</p> </section> ); } return ( <LiveblocksWrapper permissions={permissions} roomId={document.roomId}> <div className="container mx-auto px-4 py-8"> <div className="flex justify-between items-center mb-8"> <h1 className="text-3xl font-bold">{document.title}</h1> <div className="flex gap-4"> <ShareDocument permission={permissions.update} documentId={params.id} /> <DeleteDocument documentId={params.id} permission={permissions.delete} /> </div> </div> <CollaborativeEditor isReadOnly={!permissions.update} /> </div> </LiveblocksWrapper> ); } Component Start When the component starts, it first checks if a user is logged in. This makes sure only authorized users can see and use the document. User Authentication Check If the user is logged in, the component gets the permissions needed for the document. If no user is logged in, it sends them to the login page to stop unauthorized access. Get Permissions The component asks for permissions for three main actions: read, update, and delete. Based on the response: If read permission is given, the component tries to get the document. If read permission is not given, it shows a message: “You do not have permission to view this document.” Get Document If the component has the right permissions, it calls an API to get the document by its ID. If the document is found, it shows the content. If the document is not found or the ID is wrong, it shows a message: “Document not found.” Show Document After getting the document, the component shows the document’s title and includes a collaborative editor. Depending on the permissions: If the user has update permissions, the editor can be used to edit. If the user does not have update permissions, the editor is in read-only mode, letting users see but not change the content. Show Options The component shows extra options based on update and delete permissions: Share Option: Available if the user has update permissions. Delete Option: Available if the user has delete permissions. These options let users share the document with others or delete it if necessary. Testing Demo Let’s test what we have built. First, create three new users using the register function: Owner: owner@gmail.com Editor: editor@gmail.com Viewer: viewer@gmail.com Then, log in with the Owner’s credentials and create a document titled “Testing ReBAC on document with Permit.io”. Go to the page of the created document and add some content. Next, click the share button and share the article with the other two users: Editor and Owner, assigning them the editor and owner roles, respectively. Now log in with three different users in three separate windows and watch it in action: live cursor, connected user avatars, and live editing. See, the owner has full access to share, edit, and delete the document. The editor can only edit and share the document, while the reader can only view it, with no ability to share or delete it. https://youtu.be/z_5AMOzKknQ?embedable=true Conclusion In this article, we’ve created a secure, real-time document editor using Next.js, Appwrite, Liveblocks, and Permit.io with ReBAC. This setup allows for": Real-time collaboration with presence awareness Secure login and document storage Detailed, relationship-based access control Using ReBAC through Permit.io, we’ve built a flexible permission system that can handle complex access needs. This ensures that document access and editing rights are managed securely and efficiently, even as your app grows. Keep in mind that making a production-ready collaborative editor involves more, like handling errors, making updates quickly, and resolving conflicts. However, this setup gives you a strong base for building advanced collaborative tools with good security. Resources Find all the code files of this project in this GitHub Repo Learn more about ReBAC by Permit.io Want to learn more about implementing authorization? Got questions? Reach out to Permit.io Slack community! That’s all for now. Thank you for reading! TL;DR Learn how to build a secure, real-time collaborative document editor with Next.js, Appwrite, Liveblocks, and Permit.io using ReBAC for flexible and secure relationship-based access control. Features include: Real-time collaboration with presence awareness. Secure storage and login management. Scalable permissions for complex access needs. Real-time collaboration with presence awareness. Real-time collaboration with presence awareness. Real-time collaboration Secure storage and login management. Secure storage and login management. Secure storage Scalable permissions for complex access needs. Scalable permissions for complex access needs. Scalable permissions This guide offers a strong foundation for building advanced collaborative tools. To make it production-ready, you’ll need to implement additional steps like error handling and conflict resolution based on your requirements. Introduction Collaborative tools such as Figma, Google Docs, and Notion are essential for teams spread across different locations or time zones. These platforms simplify working together, but they also introduce challenges like ensuring the right people have the right access without compromising sensitive information. That’s where proper access control comes into play. In this guide, we will build a collaborative document editor using Next.js for the front end, Appwrite for authentication and storage, Liveblocks for smooth collaboration, and Permit.io to manage advanced authorization with Relationship-Based Access Control (ReBAC). Prerequisites Before we start, make sure you have these tools installed on your computer Node.js (v18 or later) Npm (v10 or later) Node.js (v18 or later) Node.js (v18 or later) Npm (v10 or later) Npm (v10 or later) You should also know the basics of React and TypeScript, as we’ll be using them in this tutorial. Tools we will use To build the real-time collaborative document editor, we will use the following tools. Let’s discuss their purpose and how they work together. Appwrite Appwrite is an open-source backend-as-a-service platform that offers solutions for authentication, databases, functions, storage, and messaging for your projects using the frameworks and languages you prefer. In this tutorial, we will use its authentication and storage solutions for user login/signup and to store the document content. Liveblocks Liveblocks is a platform that lets you add collaborative editing, comments, and notifications to your app. It offers a set of tools that you can use to include collaboration features, so you can pick what you need based on your needs. Liveblocks works well with popular frontend frameworks and libraries, making it easy to quickly add real-time collaboration to any application. Permit.io Permit.io Building authorization logic from scratch takes a lot of time. Permit.io makes this easier by providing a simple interface to manage permissions separately from your code. This keeps your access rules organized, simplifies management, and reduces the effort needed to maintain your code. In this tutorial, we will use Relationship-Based Access Control (ReBAC), an authorization model that is more flexible than traditional role-based access control. ReBAC allows you to set access rules based on the relationships between users and resources. For our document editor, this means we can easily set up permissions like: Document owners have full control over their documents Editors are able to change content but not delete the document Viewers having read-only access Document owners have full control over their documents Document owners have full control over their documents Editors are able to change content but not delete the document Editors are able to change content but not delete the document Viewers having read-only access Viewers having read-only access Next.js Next.js is a popular framework for building server-side rendered web applications quickly and efficiently. Here is the application structure. Next.js ./src ├── app │ ├── Providers.tsx │ ├── Room.tsx │ ├── api │ │ └── liveblocks-auth │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ └── room │ └── page.tsx ├── components │ ├── Avatars.module.css │ ├── Avatars.tsx │ ├── ConnectToRoom.module.css │ ├── ConnectToRoom.tsx │ ├── Editor.module.css │ ├── Editor.tsx │ ├── ErrorListener.module.css │ ├── ErrorListener.tsx │ ├── Loading.module.css │ ├── Loading.tsx │ ├── Toolbar.module.css │ └── Toolbar.tsx ├── globals.css ├── hooks │ └── useRoomId.ts └── liveblocks.config.ts ./src ├── app │ ├── Providers.tsx │ ├── Room.tsx │ ├── api │ │ └── liveblocks-auth │ │ └── route.ts │ ├── layout.tsx │ ├── page.tsx │ └── room │ └── page.tsx ├── components │ ├── Avatars.module.css │ ├── Avatars.tsx │ ├── ConnectToRoom.module.css │ ├── ConnectToRoom.tsx │ ├── Editor.module.css │ ├── Editor.tsx │ ├── ErrorListener.module.css │ ├── ErrorListener.tsx │ ├── Loading.module.css │ ├── Loading.tsx │ ├── Toolbar.module.css │ └── Toolbar.tsx ├── globals.css ├── hooks │ └── useRoomId.ts └── liveblocks.config.ts Alright, we talked about the tools we’ll use and looked at the project structure. Now, let’s start setting up the development environment. Setting Up the Development Environment Let’s start by creating a new Next.js project and installing the needed dependencies. npx create-next-app@latest collaborative-docs --typescript cd collaborative-docs npx create-next-app@latest collaborative-docs --typescript cd collaborative-docs For components, we will use shadcn UI, so let’s set it up. npx shadcn@latest init npx shadcn@latest init This will install shadcn in our project. Now, let’s add the components. npx shadcn@latest add alert alert-dialog avatar button card checkbox command dialog dropdown-menu form input label popover toast tooltip tabs npx shadcn@latest add alert alert-dialog avatar button card checkbox command dialog dropdown-menu form input label popover toast tooltip tabs Setup Appwrite First, we need to set up Appwrite for authentication and document storage. Visit https://cloud.appwrite.io/ , sign up, then set up the organization and select the free plan, which is enough for our needs. https://cloud.appwrite.io/ Click on the “Create Project” button and go to the project creation form. Provide the project name “Collaborative Docs” and click on “Next.” We will use the default region and then click on “Create.” We will be using Appwrite for our web application, so let’s add a Web platform. Provide the name “Collaborative Docs” and the hostname “localhost,” then click next. We can skip the optional steps and move forward. To authenticate users, we will use the email/password method. Go to the Auth section in the left panel, enable “email/password,” and disable other methods. Next, go to the security tab and set the maximum session limit to 1. Alright, we have set up the authentication method. Now, let’s create a database and a collection to store the data. Go to the “Databases” section in the left panel and click on “Create Database”. Then provide the name “collaborative_docs_db” and create it. After creating the database, you will be redirected to the collections page. From there, click on the “Create collection” button. Then provide the name “document_collection” and create it. After creating the collection, you will be redirected to the “document_collection” page. Then, go to the “Settings” tab, scroll down to permissions and document security, and add permissions for “Users.” Enforce document security so that only the user who created the document can perform the allowed operations. Go to the “Attributes” tab and create four attributes: “Attributes” roomId (string, 36, required) storageData (string, 1073741824) title (string, 128) created_by (string, 20, required) roomId (string, 36, required) roomId (string, 36, required) roomId storageData (string, 1073741824) storageData (string, 1073741824) storageData title (string, 128) title (string, 128) title created_by (string, 20, required) created_by (string, 20, required) created_by At this point, you should have four attributes set up for the document. Alright, the platform setup is done. Now, let’s install the Appwrite dependencies. npm install appwrite node-appwrite@11.1.1 npm install appwrite node-appwrite@11.1.1 Create a .env.development.local file to store keys and secrets as environment variables. .env.development.local NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 NEXT_PUBLIC_APPWRITE_PROJECT_ID=your_project_key NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 NEXT_PUBLIC_APPWRITE_PROJECT_ID=your_project_key Create a new file lib/appwrite.ts lib/appwrite.ts import { Client, Account, Databases } from 'appwrite'; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || '') // Your API Endpoint .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || ''); // Your project ID; export const account = new Account(client); export const databases = new Databases(client); export const APPWRITE_CLIENT = { account, databases, }; import { Client, Account, Databases } from 'appwrite'; const client = new Client() .setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || '') // Your API Endpoint .setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || ''); // Your project ID; export const account = new Account(client); export const databases = new Databases(client); export const APPWRITE_CLIENT = { account, databases, }; Great, the Appwrite setup is done. Now, let’s set up Liveblocks for real-time collaboration. Setup Liveblocks Go to https://liveblocks.io/ and sign up. You will be taken to the dashboard, where two projects will be created for you. https://liveblocks.io/ https://liveblocks.io/ Click on “Project development,” then go to the “API Keys” section on the left panel. Copy the Public key and secret key. Now add the NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY and NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY to .env.development.local NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY .env.development.local NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY=pk_dev_xxxxxx_xxxxxxxx NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY=sk_dev_xxxxxx_xxxxxxxx NEXT_PUBLIC_LIVE_BLOCKS_PUBLIC_API_KEY=pk_dev_xxxxxx_xxxxxxxx NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY=sk_dev_xxxxxx_xxxxxxxx Later, we will use this public API key in LiveblocksProvider LiveblocksProvider Let’s install the Liveblocks dependencies and also Tiptap (for building a rich text editor). npm install @liveblocks/client @liveblocks/node @liveblocks/react @liveblocks/yjs yjs @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror npm install @liveblocks/client @liveblocks/node @liveblocks/react @liveblocks/yjs yjs @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor y-prosemirror Create a new file lib/liveblocks.ts lib/liveblocks.ts import { Liveblocks } from '@liveblocks/node'; export const LIVEBLOCKS_CLIENT = new Liveblocks({ secret: process.env.NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY!, }); import { Liveblocks } from '@liveblocks/node'; export const LIVEBLOCKS_CLIENT = new Liveblocks({ secret: process.env.NEXT_PUBLIC_LIVE_BLOCKS_SECRET_KEY!, }); Create a new file liveblocks.config.ts to define types. liveblocks.config.ts declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number } | null }; UserMeta: { id: string; info: { name: string; color: string; }; }; } } export {}; declare global { interface Liveblocks { Presence: { cursor: { x: number; y: number } | null }; UserMeta: { id: string; info: { name: string; color: string; }; }; } } export {}; For now, this completes the Liveblocks setup. Let’s move on to the next section to set up the most important component of the application permit.io . permit.io Setup Permit.io To get started, you’ll first need to create an account. Sign up on Permit.io and create a new workspace for your team or organization. This will allow you to invite team members and use all the collaboration features Permit.io offers. Permit.io Permit.io After creating a workspace, create a project and name it “ Collaborative Docs .” Collaborative Docs Then, create an environment called Development . Development Now click on “open dashboard,” then go to the “policy” section in the left panel, and select the “resources” tab. Click on “Add Resource,” and a form panel will open. Provide the following details and save. Provide the following details and save. Name: Document Key: document Actions: create, delete, read, update ReBAC Roles: owner, editor, viewer Name: Document Name: Document Key: document Key: document Actions: create, delete, read, update Actions: create, delete, read, update ReBAC Roles: owner, editor, viewer ReBAC Roles: owner, editor, viewer We want to set up roles for document resources so that every instance has its own roles like owner, viewer, and editor. Navigate to the “Policy Editor” tab and set up the permissions for the roles we created. Owner: should have all permissions Editor: should have update and read permissions Viewer: should have read-only permission Owner: should have all permissions Owner: should have all permissions Editor: should have update and read permissions Editor: should have update and read permissions Viewer: should have read-only permission Viewer: should have read-only permission Super easy, right? With just a few steps, our resources and permissions are set. That’s the benefit of using Permit.io — it makes everything easy. Permit.io Permit.io Let’s install the permit.io dependencies and set up the client so we can interact with permit.io programmatically. It supports SDKs in many languages, and we will use the Node.js SDK. npm install permitio npm install permitio The SDK needs an API key. You can get it by going to Settings → API Keys. Copy the secret and add it as an environment variable NEXT_PUBLIC_PERMIT_API_KEY in .env.development.local . NEXT_PUBLIC_PERMIT_API_KEY .env.development.local NEXT_PUBLIC_PERMIT_API_KEY=permit_key_xxxxxxxxxxxxx NEXT_PUBLIC_PERMIT_API_KEY=permit_key_xxxxxxxxxxxxx To use ReBAC functionality with the Permit.io SDK, we will install the PDP (Policy-Decision-Point) via Docker, as Permit.io currently recommends it. For zero latency, great performance, high availability, and improved security, they are working on making it available directly on the cloud soon. Permit.io Permit.io zero latency, great performance, high availability, and improved security, You can install Docker by visiting https://docs.docker.com/get-started/get-docker/ . It’s very easy just install it and start Docker Desktop. https://docs.docker.com/get-started/get-docker/ https://docs.docker.com/get-started/get-docker/ Create a docker-compose.yml file in the root directory. docker-compose.yml version: '3' services: pdp-service: image: permitio/pdp-v2:latest ports: - "7766:7000" environment: - PDP_API_KEY=permit_key_xxxxxxxxx - PDP_DEBUG=True stdin_open: true tty: true version: '3' services: pdp-service: image: permitio/pdp-v2:latest ports: - "7766:7000" environment: - PDP_API_KEY=permit_key_xxxxxxxxx - PDP_DEBUG=True stdin_open: true tty: true Then run the command to start the PDP service. docker compose up -d docker compose up -d Once it’s running, you can visit http://localhost:7766 and you should see the response below. http://localhost:7766 http://localhost:7766 { "status": "ok" } { "status": "ok" } Great, now let’s set up the Permit.io SDK by creating lib/permitio.ts . Permit.io lib/permitio.ts import { Permit } from 'permitio'; const pdpUrl = process.env.PDP_URL || 'http://localhost:7766'; const apiKey = process.env.NEXT_PUBLIC_PERMIT_API_KEY!; export const PERMITIO_SDK = new Permit({ token: apiKey, pdp: pdpUrl, }); import { Permit } from 'permitio'; const pdpUrl = process.env.PDP_URL || 'http://localhost:7766'; const apiKey = process.env.NEXT_PUBLIC_PERMIT_API_KEY!; export const PERMITIO_SDK = new Permit({ token: apiKey, pdp: pdpUrl, }); Awesome, we have set up all the components of our application. In the next section, we will implement authentication for sign-up, login, and logout with Appwrite, as well as authorization using Permit ReBAC with their SDK. Implementing Authentication and Authorization We will manage some global states in our application, so let’s install zustand for state management. zustand npm install zustand npm install zustand After installing Zustand, let’s create a store/authStore.ts to manage authentication states easily. store/authStore.ts import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models } from 'appwrite'; const { account } = APPWRITE_CLIENT; const ERROR_TIMEOUT = 8000; interface AuthState { user: Models.User<Models.Preferences> | null; session: Models.Session | null; isLoading: boolean; error: string | null; login: (email: string, password: string) => Promise<void>; register: (email: string, password: string, name: string) => Promise<void>; logout: () => Promise<void>; checkAuth: () => Promise<void>; } export const useAuthStore = create<AuthState>()( persist( (set) => ({ user: null, isLoading: true, error: null, session: null, login: async (email, password) => { try { set({ isLoading: true, error: null }); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, register: async (email, password, name) => { try { set({ isLoading: true, error: null }); await account.create(ID.unique(), email, password, name); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, logout: async () => { try { set({ isLoading: true, error: null }); await account.deleteSession('current'); set({ user: null, isLoading: false, session: null }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, checkAuth: async () => { try { set({ isLoading: true }); const user = await account.get(); const session = await account.getSession('current'); set({ user, session }); } catch (error) { console.error("Couldn't get user", (error as Error).message); } finally { set({ isLoading: false }); } }, }), { name: 'collabdocs-session', // name of item in the storage (must be unique) partialize: (state) => ({ session: state.session, }), } ) ); import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models } from 'appwrite'; const { account } = APPWRITE_CLIENT; const ERROR_TIMEOUT = 8000; interface AuthState { user: Models.User<Models.Preferences> | null; session: Models.Session | null; isLoading: boolean; error: string | null; login: (email: string, password: string) => Promise<void>; register: (email: string, password: string, name: string) => Promise<void>; logout: () => Promise<void>; checkAuth: () => Promise<void>; } export const useAuthStore = create<AuthState>()( persist( (set) => ({ user: null, isLoading: true, error: null, session: null, login: async (email, password) => { try { set({ isLoading: true, error: null }); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, register: async (email, password, name) => { try { set({ isLoading: true, error: null }); await account.create(ID.unique(), email, password, name); await account.createEmailPasswordSession(email, password); const session = await account.getSession('current'); const user = await account.get(); set({ user, isLoading: false, session }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, logout: async () => { try { set({ isLoading: true, error: null }); await account.deleteSession('current'); set({ user: null, isLoading: false, session: null }); } catch (error) { set({ error: (error as Error).message, isLoading: false }); setTimeout(() => { set({ error: null }); }, ERROR_TIMEOUT); } }, checkAuth: async () => { try { set({ isLoading: true }); const user = await account.get(); const session = await account.getSession('current'); set({ user, session }); } catch (error) { console.error("Couldn't get user", (error as Error).message); } finally { set({ isLoading: false }); } }, }), { name: 'collabdocs-session', // name of item in the storage (must be unique) partialize: (state) => ({ session: state.session, }), } ) ); Here we are using Zustand create to make the useAuthStore hook. It has methods for login, registration, logout, and checking authentication status while managing user and session data. The session state is saved in local storage to keep session information even after the page reloads. create Let’s create our first component components/navbar.tsx and add the following code. It’s a simple component with a brand name and a link or logout button based on the user's authentication status. components/navbar.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; import { FileText } from 'lucide-react'; import Link from 'next/link'; import { Button } from './ui/button'; export default function Navbar() { const { user, logout } = useAuthStore(); return ( <header className="px-4 lg:px-6 h-14 flex items-center border-b shadow-md"> <Link className="flex items-center justify-center" href="/"> <FileText className="h-6 w-6 mr-2" /> <span className="font-bold">CollabDocs</span> </Link> <nav className="ml-auto flex gap-4 sm:gap-6"> {user ? ( <div className="flex gap-4 items-center"> <span> {user.name} ({user.email}) </span> <Button variant="outline" onClick={logout}> Logout </Button> </div> ) : ( <Link className="text-sm font-medium hover:underline underline-offset-4" href="/#features" > Features </Link> )} </nav> </header> ); } 'use client'; import { useAuthStore } from '@/store/authStore'; import { FileText } from 'lucide-react'; import Link from 'next/link'; import { Button } from './ui/button'; export default function Navbar() { const { user, logout } = useAuthStore(); return ( <header className="px-4 lg:px-6 h-14 flex items-center border-b shadow-md"> <Link className="flex items-center justify-center" href="/"> <FileText className="h-6 w-6 mr-2" /> <span className="font-bold">CollabDocs</span> </Link> <nav className="ml-auto flex gap-4 sm:gap-6"> {user ? ( <div className="flex gap-4 items-center"> <span> {user.name} ({user.email}) </span> <Button variant="outline" onClick={logout}> Logout </Button> </div> ) : ( <Link className="text-sm font-medium hover:underline underline-offset-4" href="/#features" > Features </Link> )} </nav> </header> ); } Since we need to keep the authentication state and only let logged-in users access the dashboard page (which we will create soon), let’s create a components/auth-wrapper.tsx component. components/auth-wrapper.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; import { LoaderIcon } from 'lucide-react'; import { redirect, usePathname } from 'next/navigation'; import { useEffect } from 'react'; export default function AuthWrapper({ children, }: { children: React.ReactNode; }) { const pathname = usePathname(); const { user, isLoading, checkAuth } = useAuthStore(); useEffect(() => { checkAuth(); }, [checkAuth]); if (isLoading) { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); } if (!user && pathname.startsWith('/dashboard')) { redirect('/login'); } return children; } 'use client'; import { useAuthStore } from '@/store/authStore'; import { LoaderIcon } from 'lucide-react'; import { redirect, usePathname } from 'next/navigation'; import { useEffect } from 'react'; export default function AuthWrapper({ children, }: { children: React.ReactNode; }) { const pathname = usePathname(); const { user, isLoading, checkAuth } = useAuthStore(); useEffect(() => { checkAuth(); }, [checkAuth]); if (isLoading) { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); } if (!user && pathname.startsWith('/dashboard')) { redirect('/login'); } return children; } AuthWrapper will take children as a prop and check if the user is authenticated. If they are, it will render the children. If not, and they are trying to access the dashboard page, it will redirect them to the login page. Then update the root layout component with the following code: import Navbar from '@/components/navbar'; import AuthWrapper from '@/components/auth-wrapper'; import { Toaster } from '@/components/ui/toaster'; ... // existing code export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <AuthWrapper> <Navbar /> <main>{children}</main> </AuthWrapper> <Toaster /> </body> </html> ); } import Navbar from '@/components/navbar'; import AuthWrapper from '@/components/auth-wrapper'; import { Toaster } from '@/components/ui/toaster'; ... // existing code export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <AuthWrapper> <Navbar /> <main>{children}</main> </AuthWrapper> <Toaster /> </body> </html> ); } Cool, the layout setup is done. Now let’s create a landing page for our application. Create components/landing-page.tsx and add the following code. components/landing-page.tsx 'use client'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { FileText, Users, Share2, ShieldCheck } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; export default function LandingPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col min-h-screen"> <main className="flex-1"> <section className="w-full py-12 md:py-24 lg:py-32 xl:py-48"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center space-y-4 text-center"> <div className="space-y-2"> <h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none"> Collaborate on Documents in Real-Time </h1> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Create, edit, and share documents with ease. Powerful collaboration tools for teams of all sizes. </p> </div> {user ? ( <Button asChild> <Link href="/dashboard">Dashboard</Link> </Button> ) : ( <div className="space-x-4"> <Button asChild> <Link href="/signup">Get Started</Link> </Button> <Button variant="outline" asChild> <Link href="/login">Log In</Link> </Button> </div> )} </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-800" id="features" > <div className="px-4 md:px-6"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl text-center mb-8"> Key Features </h2> <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3"> <div className="flex flex-col items-center text-center"> <FileText className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Rich Text Editing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Create beautiful documents with our powerful rich text editor. </p> </div> <div className="flex flex-col items-center text-center"> <Users className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Real-Time Collaboration</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Work together in real-time with your team members. </p> </div> <div className="flex flex-col items-center text-center"> <Share2 className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Easy Sharing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Share your documents with others quickly and securely. </p> </div> <div className="flex flex-col items-center text-center"> <ShieldCheck className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Role-Based Access</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Control access with Owner, Editor, and Viewer roles. </p> </div> </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center justify-center space-y-4 text-center"> <div className="space-y-2"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl"> Start Collaborating Today </h2> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Join thousands of teams already using CollabDocs to streamline their document workflows. </p> </div> <Button asChild size="lg"> <Link href={user ? '/dashboard' : '/signup'}> Sign Up for Free </Link> </Button> </div> </div> </section> </main> <footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t"> <p className="text-xs text-gray-500 dark:text-gray-400"> © {new Date().getFullYear()} CollabDocs. All rights reserved. </p> <nav className="sm:ml-auto flex gap-4 sm:gap-6"> <Link className="text-xs hover:underline underline-offset-4" href="#"> Terms of Service </Link> <Link className="text-xs hover:underline underline-offset-4" href="#"> Privacy </Link> </nav> </footer> </div> ); } 'use client'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { FileText, Users, Share2, ShieldCheck } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; export default function LandingPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col min-h-screen"> <main className="flex-1"> <section className="w-full py-12 md:py-24 lg:py-32 xl:py-48"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center space-y-4 text-center"> <div className="space-y-2"> <h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none"> Collaborate on Documents in Real-Time </h1> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Create, edit, and share documents with ease. Powerful collaboration tools for teams of all sizes. </p> </div> {user ? ( <Button asChild> <Link href="/dashboard">Dashboard</Link> </Button> ) : ( <div className="space-x-4"> <Button asChild> <Link href="/signup">Get Started</Link> </Button> <Button variant="outline" asChild> <Link href="/login">Log In</Link> </Button> </div> )} </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-800" id="features" > <div className="px-4 md:px-6"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl text-center mb-8"> Key Features </h2> <div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-3"> <div className="flex flex-col items-center text-center"> <FileText className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Rich Text Editing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Create beautiful documents with our powerful rich text editor. </p> </div> <div className="flex flex-col items-center text-center"> <Users className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Real-Time Collaboration</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Work together in real-time with your team members. </p> </div> <div className="flex flex-col items-center text-center"> <Share2 className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Easy Sharing</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Share your documents with others quickly and securely. </p> </div> <div className="flex flex-col items-center text-center"> <ShieldCheck className="h-12 w-12 mb-4 text-primary" /> <h3 className="text-lg font-bold">Role-Based Access</h3> <p className="text-sm text-gray-500 dark:text-gray-400"> Control access with Owner, Editor, and Viewer roles. </p> </div> </div> </div> </section> <section className="w-full py-12 md:py-24 lg:py-32"> <div className="px-4 md:px-6"> <div className="flex flex-col items-center justify-center space-y-4 text-center"> <div className="space-y-2"> <h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl"> Start Collaborating Today </h2> <p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400"> Join thousands of teams already using CollabDocs to streamline their document workflows. </p> </div> <Button asChild size="lg"> <Link href={user ? '/dashboard' : '/signup'}> Sign Up for Free </Link> </Button> </div> </div> </section> </main> <footer className="flex flex-col gap-2 sm:flex-row py-6 w-full shrink-0 items-center px-4 md:px-6 border-t"> <p className="text-xs text-gray-500 dark:text-gray-400"> © {new Date().getFullYear()} CollabDocs. All rights reserved. </p> <nav className="sm:ml-auto flex gap-4 sm:gap-6"> <Link className="text-xs hover:underline underline-offset-4" href="#"> Terms of Service </Link> <Link className="text-xs hover:underline underline-offset-4" href="#"> Privacy </Link> </nav> </footer> </div> ); } Then update the app/page.tsx with the code below. app/page.tsx import LandingPage from '@/components/landing-page'; export default function Home() { return <LandingPage />; } import LandingPage from '@/components/landing-page'; export default function Home() { return <LandingPage />; } Start the development server and visit localhost:3000. You will see a nice landing page for our application. Alright, the landing page is done, now we will be creating three pages, login, signup, and dashboard. Create components/login.tsx and components/register.tsx and add the following code. components/login.tsx components/register.tsx // login.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Login() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const { login, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await login(email, password); if (!error) { router.push(nextPath ?? '/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Login</CardTitle> <CardDescription> Enter your email and password to login. </CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Login</Button> <Link href={nextPath ? `/signup?next=${nextPath}` : '/signup'}> Sign up </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } // login.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Login() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const { login, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await login(email, password); if (!error) { router.push(nextPath ?? '/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Login</CardTitle> <CardDescription> Enter your email and password to login. </CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Login</Button> <Link href={nextPath ? `/signup?next=${nextPath}` : '/signup'}> Sign up </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } Here we have a simple form with email and password fields. When the user submits, we call the login method from the useAuthStore hook. If the login is successful, we redirect the user to the dashboard page. If there is an error, it will be shown using the error state. login useAuthStore dashboard error // register.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Register() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const { register, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await register(email, password, name); if (!error) { router.push('/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Register</CardTitle> <CardDescription>Create a new account.</CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="name">Name</Label> <Input id="name" placeholder="Enter your name" value={name} onChange={(e) => setName(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Register</Button> <Link href={nextPath ? `/login?next=${nextPath}` : '/login'}> Login </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } // register.tsx 'use client'; import { useState } from 'react'; import { useAuthStore } from '../store/authStore'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; export default function Register() { const searchParams = useSearchParams(); const nextPath = searchParams.get('next'); const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [name, setName] = useState(''); const { register, error } = useAuthStore(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); await register(email, password, name); if (!error) { router.push('/dashboard'); } }; return ( <Card className="w-[350px]"> <CardHeader> <CardTitle>Register</CardTitle> <CardDescription>Create a new account.</CardDescription> </CardHeader> <form onSubmit={handleSubmit}> <CardContent> <div className="grid w-full items-center gap-4"> <div className="flex flex-col space-y-1.5"> <Label htmlFor="name">Name</Label> <Input id="name" placeholder="Enter your name" value={name} onChange={(e) => setName(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="flex flex-col space-y-1.5"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} required /> </div> </div> </CardContent> <CardFooter className="flex justify-between"> <Button type="submit">Register</Button> <Link href={nextPath ? `/login?next=${nextPath}` : '/login'}> Login </Link> </CardFooter> </form> {error && <p className="text-red-500 text-center mt-2 py-2">{error}</p>} </Card> ); } The Register component is simple, with fields for name, email, and password. When the user submits, we call the register method from the useAuthStore hook. If registration is successful, the user is redirected to the dashboard page. If there's an error, it will be shown using the error state. register useAuthStore dashboard error Create the login and signup pages and display the respective components. // app/login/page.tsx import Login from '@/components/login'; export default function LoginPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Login /> </div> ); } // app/login/page.tsx import Login from '@/components/login'; export default function LoginPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Login /> </div> ); } // app/signup/page.tsx import Register from '@/components/register'; export default function SignUpPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Register />; </div> ); } // app/signup/page.tsx import Register from '@/components/register'; export default function SignUpPage() { return ( <div className="flex flex-col items-center justify-center min-h-screen"> <Register />; </div> ); } Create a basic dashboard page for now, and we will update it later. // app/dashboard/page.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; export default function DashboardPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col items-center justify-center min-h-screen"> Welcome to the dashboard, {user?.name}! </div> ); } // app/dashboard/page.tsx 'use client'; import { useAuthStore } from '@/store/authStore'; export default function DashboardPage() { const { user } = useAuthStore(); return ( <div className="flex flex-col items-center justify-center min-h-screen"> Welcome to the dashboard, {user?.name}! </div> ); } Now start the dev server using the npm run dev command. Try visiting the /dashboard page, and you will be redirected to the login page. This happens because we added AuthWrapper in the root layout, which checks user authentication and redirects accordingly. npm run dev /dashboard Try to register with a valid name, email, and password, and you will be redirected to the dashboard page. Authentication is set up and working as expected. Now, let’s add server actions for authorization using the Permit.io SDK. Permit.io Permit.io Permit.io As authorization code should run on the server side, let’s create app/actions.ts and add the following initial code app/actions.ts // app/actions.ts 'use server'; import { PERMITIO_SDK } from '@/lib/permitio'; // Permit.io actions interface User { email: string; key: string; } interface ResourceInstance { key: string; resource: string; } interface ResourceInstanceRole { user: string; role: string; resource_instance: string; } export type PermissionType = 'read' | 'create' | 'delete' | 'update'; interface ResourcePermission { user: string; resource_instance: string; permissions: PermissionType[]; } // app/actions.ts 'use server'; import { PERMITIO_SDK } from '@/lib/permitio'; // Permit.io actions interface User { email: string; key: string; } interface ResourceInstance { key: string; resource: string; } interface ResourceInstanceRole { user: string; role: string; resource_instance: string; } export type PermissionType = 'read' | 'create' | 'delete' | 'update'; interface ResourcePermission { user: string; resource_instance: string; permissions: PermissionType[]; } When a new user signs up in our application, we need to sync that user with Permit.io . Let’s add our first action, syncUserWithPermit . Permit.io syncUserWithPermit // app/actions.ts ... /** * * @param user `{email: string, key: string}` */ export async function syncUserWithPermit(user: User) { try { const syncedUser = await PERMITIO_SDK.api.syncUser(user); console.log('User synced with Permit.io', syncedUser.email); } catch (error) { console.error(error); } } // app/actions.ts ... /** * * @param user `{email: string, key: string}` */ export async function syncUserWithPermit(user: User) { try { const syncedUser = await PERMITIO_SDK.api.syncUser(user); console.log('User synced with Permit.io', syncedUser.email); } catch (error) { console.error(error); } } Each user’s email ID will be unique, so we will use it for both the email and key attributes. email key Now let’s use syncUserWithPermit in the register method of our AuthStore. This way, when a user is created, they are also synced with Permit.io . syncUserWithPermit register Permit.io // store/authStore.ts ... register: async (email, password, name) => { ... // sync user with Permit.io await syncUserWithPermit({ email: user.email, key: user.email }); } // store/authStore.ts ... register: async (email, password, name) => { ... // sync user with Permit.io await syncUserWithPermit({ email: user.email, key: user.email }); } Next, add three more actions that we will use later. // app/actions.ts ... async function getPermitioUser(key: string) { try { const user = await PERMITIO_SDK.api.users.getByKey(key); return user; } catch (error) { console.error(error); return null; } } /** * * @param resourceInstance `{key: string, resource: string}` * @returns createdInstance */ export async function createResourceInstance( resourceInstance: ResourceInstance ) { console.log('Creating a resource instance...'); try { const createdInstance = await PERMITIO_SDK.api.resourceInstances.create({ key: resourceInstance.key, tenant: 'default', resource: resourceInstance.resource, }); console.log(`Resource instance created: ${createdInstance.key}`); return createdInstance; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourceInstanceRole `{user: string, role: string, resource_instance: string}` * @returns assignedRole */ export async function assignResourceInstanceRoleToUser( resourceInstanceRole: ResourceInstanceRole ) { try { const user = await getPermitioUser(resourceInstanceRole.user); if (!user) { await syncUserWithPermit({ email: resourceInstanceRole.user, key: resourceInstanceRole.user, }); } const assignedRole = await PERMITIO_SDK.api.roleAssignments.assign({ user: resourceInstanceRole.user, role: resourceInstanceRole.role, resource_instance: resourceInstanceRole.resource_instance, tenant: 'default', }); console.log(`Role assigned: ${assignedRole.role} to ${assignedRole.user}`); return assignedRole; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourcePermission `{user: string, resource_instance: string, permission: string}` * @returns permitted */ export async function getResourcePermissions( resourcePermission: ResourcePermission ) { try { const permissions = resourcePermission.permissions; const permissionMap: Record<PermissionType, boolean> = { read: false, create: false, delete: false, update: false, }; for await (const permission of permissions) { permissionMap[permission] = await PERMITIO_SDK.check( resourcePermission.user, permission, resourcePermission.resource_instance ); } return permissionMap; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return { read: false, create: false, delete: false, update: false, }; } } // app/actions.ts ... async function getPermitioUser(key: string) { try { const user = await PERMITIO_SDK.api.users.getByKey(key); return user; } catch (error) { console.error(error); return null; } } /** * * @param resourceInstance `{key: string, resource: string}` * @returns createdInstance */ export async function createResourceInstance( resourceInstance: ResourceInstance ) { console.log('Creating a resource instance...'); try { const createdInstance = await PERMITIO_SDK.api.resourceInstances.create({ key: resourceInstance.key, tenant: 'default', resource: resourceInstance.resource, }); console.log(`Resource instance created: ${createdInstance.key}`); return createdInstance; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourceInstanceRole `{user: string, role: string, resource_instance: string}` * @returns assignedRole */ export async function assignResourceInstanceRoleToUser( resourceInstanceRole: ResourceInstanceRole ) { try { const user = await getPermitioUser(resourceInstanceRole.user); if (!user) { await syncUserWithPermit({ email: resourceInstanceRole.user, key: resourceInstanceRole.user, }); } const assignedRole = await PERMITIO_SDK.api.roleAssignments.assign({ user: resourceInstanceRole.user, role: resourceInstanceRole.role, resource_instance: resourceInstanceRole.resource_instance, tenant: 'default', }); console.log(`Role assigned: ${assignedRole.role} to ${assignedRole.user}`); return assignedRole; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return null; } } /** * * @param resourcePermission `{user: string, resource_instance: string, permission: string}` * @returns permitted */ export async function getResourcePermissions( resourcePermission: ResourcePermission ) { try { const permissions = resourcePermission.permissions; const permissionMap: Record<PermissionType, boolean> = { read: false, create: false, delete: false, update: false, }; for await (const permission of permissions) { permissionMap[permission] = await PERMITIO_SDK.check( resourcePermission.user, permission, resourcePermission.resource_instance ); } return permissionMap; } catch (error) { if (error instanceof Error) { console.log(error.message); } else { console.log('An unknown error occurred'); } return { read: false, create: false, delete: false, update: false, }; } } createResourceInstance: We use this when a user creates a document. It creates the document instance with a unique key in Permit.io. assignResourceInstanceRoleToUser: This assigns the right role (owner, editor, or viewer) to the user for a specific resource instance. getResourcePermissions: This is a straightforward but effective method we use to obtain the resource permissions. createResourceInstance : We use this when a user creates a document. It creates the document instance with a unique key in Permit.io . createResourceInstance Permit.io assignResourceInstanceRoleToUser : This assigns the right role (owner, editor, or viewer) to the user for a specific resource instance. assignResourceInstanceRoleToUser getResourcePermissions: This is a straightforward but effective method we use to obtain the resource permissions. getResourcePermissions: With just a few lines of code, we have the authorization logic. That’s the power of Permit.io . Permit.io Great, now let’s create our dashboard page so users can view their documents, create new ones, and search through them. // components/loader.tsx import { LoaderIcon } from 'lucide-react'; import React from 'react'; const Loader = () => { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); }; export default Loader; // components/loader.tsx import { LoaderIcon } from 'lucide-react'; import React from 'react'; const Loader = () => { return ( <section className="h-screen flex justify-center items-center"> <LoaderIcon className="w-8 h-8 animate-spin" /> </section> ); }; export default Loader; // app/dashboard/page.tsx 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { FileText, Plus, Search } from 'lucide-react'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models, Query } from 'appwrite'; import { assignResourceInstanceRoleToUser, createResourceInstance, } from '../actions'; import { useAuthStore } from '@/store/authStore'; import Loader from '@/components/loader'; import { toast } from '@/hooks/use-toast'; export interface Document extends Models.Document { roomId: string; title: string; storageData: string; created_by: string; } export default function Dashboard() { const [documents, setDocuments] = useState<Document[]>([]); const [searchTerm, setSearchTerm] = useState(''); const [newDocTitle, setNewDocTitle] = useState(''); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const { user } = useAuthStore(); const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(searchTerm.toLowerCase()) ); const fetchDocuments = async () => { setIsLoading(true); try { const response = await APPWRITE_CLIENT.databases.listDocuments<Document>( 'database_id', 'collection_id', [Query.contains('created_by', user?.$id ?? '')] ); setDocuments(response.documents); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to fetch documents. Please try again.', variant: 'destructive', }); } finally { setIsLoading(false); } }; const handleCreateDocument = async () => { setIsCreating(true); try { const documentId = ID.unique(); const response = await APPWRITE_CLIENT.databases.createDocument<Document>( 'database_id', 'collection_id', documentId, { title: newDocTitle.trim(), roomId: documentId, created_by: user?.$id ?? '', } ); const createdInstance = await createResourceInstance({ key: documentId, resource: 'document', }); if (!createdInstance) { throw new Error('Failed to create resource instance'); } const assignedRole = await assignResourceInstanceRoleToUser({ resource_instance: `document:${createdInstance.key}`, role: 'owner', user: user?.email ?? '', }); if (!assignedRole) { throw new Error('Failed to assign role'); } setDocuments((prev) => [...prev, response]); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to create document. Please try again.', variant: 'destructive', }); } finally { setIsCreating(false); setIsDialogOpen(false); } }; useEffect(() => { fetchDocuments(); }, []); if (isLoading) { return <Loader />; } return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">My Documents</h1> <div className="flex justify-between items-center mb-6"> <div className="relative w-64"> <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" /> <Input type="text" placeholder="Search documents" className="pl-8" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> </div> <Dialog modal open={isDialogOpen} onOpenChange={(value) => setIsDialogOpen(isCreating ? isCreating : value) } > <DialogTrigger asChild> <Button> <Plus className="mr-2 h-4 w-4" /> New Document </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create New Document</DialogTitle> <DialogDescription> Enter a title for your new document. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="name" className="text-right"> Title </Label> <Input id="name" value={newDocTitle} onChange={(e) => setNewDocTitle(e.target.value)} className="col-span-3" /> </div> </div> <DialogFooter> <Button onClick={handleCreateDocument}>Create</Button> </DialogFooter> </DialogContent> </Dialog> </div> {documents.length === 0 && ( <p className="text-center text-gray-500"> You don&apos;t have any documents yet. Click on the{' '} <strong> New Document </strong> button to create one. </p> )} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {filteredDocuments.map((doc) => ( <Card key={doc.$id}> <CardHeader> <CardTitle>{doc.title}</CardTitle> <CardDescription> Last edited: {new Date(doc.$updatedAt).toDateString()} </CardDescription> </CardHeader> <CardContent> <FileText className="h-16 w-16 text-gray-400" /> </CardContent> <CardFooter> <Button asChild> <Link href={`/document/${doc.$id}`}>Open</Link> </Button> </CardFooter> </Card> ))} </div> </div> ); } // app/dashboard/page.tsx 'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { FileText, Plus, Search } from 'lucide-react'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { ID, Models, Query } from 'appwrite'; import { assignResourceInstanceRoleToUser, createResourceInstance, } from '../actions'; import { useAuthStore } from '@/store/authStore'; import Loader from '@/components/loader'; import { toast } from '@/hooks/use-toast'; export interface Document extends Models.Document { roomId: string; title: string; storageData: string; created_by: string; } export default function Dashboard() { const [documents, setDocuments] = useState<Document[]>([]); const [searchTerm, setSearchTerm] = useState(''); const [newDocTitle, setNewDocTitle] = useState(''); const [isDialogOpen, setIsDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isCreating, setIsCreating] = useState(false); const { user } = useAuthStore(); const filteredDocuments = documents.filter((doc) => doc.title.toLowerCase().includes(searchTerm.toLowerCase()) ); const fetchDocuments = async () => { setIsLoading(true); try { const response = await APPWRITE_CLIENT.databases.listDocuments<Document>( 'database_id', 'collection_id', [Query.contains('created_by', user?.$id ?? '')] ); setDocuments(response.documents); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to fetch documents. Please try again.', variant: 'destructive', }); } finally { setIsLoading(false); } }; const handleCreateDocument = async () => { setIsCreating(true); try { const documentId = ID.unique(); const response = await APPWRITE_CLIENT.databases.createDocument<Document>( 'database_id', 'collection_id', documentId, { title: newDocTitle.trim(), roomId: documentId, created_by: user?.$id ?? '', } ); const createdInstance = await createResourceInstance({ key: documentId, resource: 'document', }); if (!createdInstance) { throw new Error('Failed to create resource instance'); } const assignedRole = await assignResourceInstanceRoleToUser({ resource_instance: `document:${createdInstance.key}`, role: 'owner', user: user?.email ?? '', }); if (!assignedRole) { throw new Error('Failed to assign role'); } setDocuments((prev) => [...prev, response]); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to create document. Please try again.', variant: 'destructive', }); } finally { setIsCreating(false); setIsDialogOpen(false); } }; useEffect(() => { fetchDocuments(); }, []); if (isLoading) { return <Loader />; } return ( <div className="container mx-auto px-4 py-8"> <h1 className="text-3xl font-bold mb-8">My Documents</h1> <div className="flex justify-between items-center mb-6"> <div className="relative w-64"> <Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" /> <Input type="text" placeholder="Search documents" className="pl-8" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> </div> <Dialog modal open={isDialogOpen} onOpenChange={(value) => setIsDialogOpen(isCreating ? isCreating : value) } > <DialogTrigger asChild> <Button> <Plus className="mr-2 h-4 w-4" /> New Document </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Create New Document</DialogTitle> <DialogDescription> Enter a title for your new document. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="name" className="text-right"> Title </Label> <Input id="name" value={newDocTitle} onChange={(e) => setNewDocTitle(e.target.value)} className="col-span-3" /> </div> </div> <DialogFooter> <Button onClick={handleCreateDocument}>Create</Button> </DialogFooter> </DialogContent> </Dialog> </div> {documents.length === 0 && ( <p className="text-center text-gray-500"> You don&apos;t have any documents yet. Click on the{' '} <strong> New Document </strong> button to create one. </p> )} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {filteredDocuments.map((doc) => ( <Card key={doc.$id}> <CardHeader> <CardTitle>{doc.title}</CardTitle> <CardDescription> Last edited: {new Date(doc.$updatedAt).toDateString()} </CardDescription> </CardHeader> <CardContent> <FileText className="h-16 w-16 text-gray-400" /> </CardContent> <CardFooter> <Button asChild> <Link href={`/document/${doc.$id}`}>Open</Link> </Button> </CardFooter> </Card> ))} </div> </div> ); } Here we have the fetchDocuments method that gets the documents created by the user. We also have the handleCreateDocument method, which creates a document and then sets up the document resource in Permit.io assigning the creator the role of "owner". fetchDocuments handleCreateDocument Permit.io Create a document titled “What is Relationship-Based Access Control (ReBAC)?” and save it. Now let’s create a document page and its components. The ShareDocument component allows users to share documents with other users. It takes two props: documentId and permission . If permission is true, the share button will appear; otherwise, nothing will appear. ShareDocument documentId permission permission // components/share-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Share2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { assignResourceInstanceRoleToUser } from '@/app/actions'; interface ShareDocumentProps { documentId: string; permission: boolean; } export function ShareDocument({ documentId, permission }: ShareDocumentProps) { const [email, setEmail] = useState(''); const [role, setRole] = useState('viewer'); const [isOpen, setIsOpen] = useState(false); const [isSharing, setIsSharing] = useState(false); const handleShare = async () => { if (!email) { toast({ title: 'Error', description: 'Please enter an email address.', variant: 'destructive', }); return; } setIsSharing(true); try { await assignResourceInstanceRoleToUser({ user: email, role, resource_instance: `document:${documentId}`, }); await navigator.clipboard.writeText(window.location.href); toast({ title: 'Success', description: `Document shared successfully. Link copied to clipboard.`, }); setIsOpen(false); setEmail(''); setRole('viewer'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to share the document. Please try again.', variant: 'destructive', }); } finally { setIsSharing(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="outline"> <Share2 className="mr-2 h-4 w-4" /> Share </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Share Document</DialogTitle> <DialogDescription> Enter the email address of the person you want to share this document with and select their role. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="email" className="text-right"> Email </Label> <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="col-span-3" placeholder="user@example.com" /> </div> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="role" className="text-right"> Role </Label> <Select value={role} onValueChange={setRole}> <SelectTrigger className="col-span-3"> <SelectValue placeholder="Select a role" /> </SelectTrigger> <SelectContent> <SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="editor">Editor</SelectItem> <SelectItem value="owner">Owner</SelectItem> </SelectContent> </Select> </div> </div> <DialogFooter> <Button onClick={handleShare} disabled={isSharing}> {isSharing ? 'Sharing...' : 'Share'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } // components/share-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { Share2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { assignResourceInstanceRoleToUser } from '@/app/actions'; interface ShareDocumentProps { documentId: string; permission: boolean; } export function ShareDocument({ documentId, permission }: ShareDocumentProps) { const [email, setEmail] = useState(''); const [role, setRole] = useState('viewer'); const [isOpen, setIsOpen] = useState(false); const [isSharing, setIsSharing] = useState(false); const handleShare = async () => { if (!email) { toast({ title: 'Error', description: 'Please enter an email address.', variant: 'destructive', }); return; } setIsSharing(true); try { await assignResourceInstanceRoleToUser({ user: email, role, resource_instance: `document:${documentId}`, }); await navigator.clipboard.writeText(window.location.href); toast({ title: 'Success', description: `Document shared successfully. Link copied to clipboard.`, }); setIsOpen(false); setEmail(''); setRole('viewer'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to share the document. Please try again.', variant: 'destructive', }); } finally { setIsSharing(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="outline"> <Share2 className="mr-2 h-4 w-4" /> Share </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Share Document</DialogTitle> <DialogDescription> Enter the email address of the person you want to share this document with and select their role. </DialogDescription> </DialogHeader> <div className="grid gap-4 py-4"> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="email" className="text-right"> Email </Label> <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="col-span-3" placeholder="user@example.com" /> </div> <div className="grid grid-cols-4 items-center gap-4"> <Label htmlFor="role" className="text-right"> Role </Label> <Select value={role} onValueChange={setRole}> <SelectTrigger className="col-span-3"> <SelectValue placeholder="Select a role" /> </SelectTrigger> <SelectContent> <SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="editor">Editor</SelectItem> <SelectItem value="owner">Owner</SelectItem> </SelectContent> </Select> </div> </div> <DialogFooter> <Button onClick={handleShare} disabled={isSharing}> {isSharing ? 'Sharing...' : 'Share'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } Next is the DeleteDocument component, which allows the user to delete the document if they have permission. If they don't, nothing will be displayed. It takes two props: documentId and permission . DeleteDocument documentId permission // components/delete-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Trash2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useRouter } from 'next/navigation'; interface DeleteDocumentProps { documentId: string; permission: boolean; } export function DeleteDocument({ documentId, permission, }: DeleteDocumentProps) { const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { setIsDeleting(true); try { await APPWRITE_CLIENT.databases.deleteDocument( 'database_id', 'collection_id', documentId ); toast({ title: 'Success', description: 'Document deleted successfully.', }); setIsOpen(false); router.push('/dashboard'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to delete the document. Please try again.', variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="destructive"> <Trash2 className="mr-2 h-4 w-4" /> Delete </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Delete Document</DialogTitle> <DialogDescription> Are you sure you want to delete this document? This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={() => setIsOpen(false)}> Cancel </Button> <Button variant="destructive" onClick={handleDelete} disabled={isDeleting} > {isDeleting ? 'Deleting...' : 'Delete'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } // components/delete-document.tsx 'use client'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Trash2 } from 'lucide-react'; import { toast } from '@/hooks/use-toast'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useRouter } from 'next/navigation'; interface DeleteDocumentProps { documentId: string; permission: boolean; } export function DeleteDocument({ documentId, permission, }: DeleteDocumentProps) { const router = useRouter(); const [isOpen, setIsOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { setIsDeleting(true); try { await APPWRITE_CLIENT.databases.deleteDocument( 'database_id', 'collection_id', documentId ); toast({ title: 'Success', description: 'Document deleted successfully.', }); setIsOpen(false); router.push('/dashboard'); } catch (error) { console.error(error); toast({ title: 'Error', description: 'Failed to delete the document. Please try again.', variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!permission) { return null; } return ( <Dialog open={isOpen} onOpenChange={setIsOpen}> <DialogTrigger asChild> <Button variant="destructive"> <Trash2 className="mr-2 h-4 w-4" /> Delete </Button> </DialogTrigger> <DialogContent className="sm:max-w-[425px]"> <DialogHeader> <DialogTitle>Delete Document</DialogTitle> <DialogDescription> Are you sure you want to delete this document? This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <Button variant="outline" onClick={() => setIsOpen(false)}> Cancel </Button> <Button variant="destructive" onClick={handleDelete} disabled={isDeleting} > {isDeleting ? 'Deleting...' : 'Delete'} </Button> </DialogFooter> </DialogContent> </Dialog> ); } Right now, it’s open to everyone, meaning anyone can access it, and it doesn’t show the collaborative editor with the content. In the next section, we will create the collaborative editor (using liveblock) and set up permissions using the Permit.io server action we wrote earlier. Permit.io Building the Collaborative Document Editor Great job following along so far! Now comes the exciting part: building the collaborative editor. This will allow different users to work together in real time. Each user will have different permissions, so they can read, update, or delete the document based on their access rights. Let’s begin by adding some CSS to our global.css file. ... /* Give a remote user a caret */ .collaboration-cursor__caret { border-left: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d; margin-left: -1px; margin-right: -1px; pointer-events: none; position: relative; word-break: normal; } /* Render the username above the caret */ .collaboration-cursor__label { font-style: normal; font-weight: 600; left: -1px; line-height: normal; position: absolute; user-select: none; white-space: nowrap; font-size: 14px; color: #fff; top: -1.4em; border-radius: 6px; border-bottom-left-radius: 0; padding: 2px 6px; pointer-events: none; } ... /* Give a remote user a caret */ .collaboration-cursor__caret { border-left: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d; margin-left: -1px; margin-right: -1px; pointer-events: none; position: relative; word-break: normal; } /* Render the username above the caret */ .collaboration-cursor__label { font-style: normal; font-weight: 600; left: -1px; line-height: normal; position: absolute; user-select: none; white-space: nowrap; font-size: 14px; color: #fff; top: -1.4em; border-radius: 6px; border-bottom-left-radius: 0; padding: 2px 6px; pointer-events: none; } Then, create a few components to make our editor easier to use. The Avatars component will display the avatars of online users who are collaborating on the same document. It uses two hooks from Liveblocks: useOthers and useSelf . useOthers useSelf // components/editor/user-avatars.tsx import { useOthers, useSelf } from '@liveblocks/react/suspense'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../ui/tooltip'; export function Avatars() { const users = useOthers(); const currentUser = useSelf(); return ( <div className="flex py-[0.75rem]"> {users.map(({ connectionId, info }) => { return ( <Avatar key={connectionId} name={info.name} color={info.color} /> ); })} {currentUser && ( <div className="relative ml-8 first:ml-0"> <Avatar color={currentUser.info.color} name={currentUser.info.name} /> </div> )} </div> ); } export function Avatar({ name, color }: { name: string; color: string }) { return ( <TooltipProvider> <Tooltip> <TooltipTrigger> <div className="flex place-content-center relative border-4 border-white rounded-full w-[42px] h-[42px] bg-[#9ca3af] ml-[-0.75rem]"> <div className="w-full h-full rounded-full flex items-center justify-center text-white" style={{ background: color }} > {name.slice(0, 2).toUpperCase()} </div> </div> </TooltipTrigger> <TooltipContent>{name}</TooltipContent> </Tooltip> </TooltipProvider> ); } // components/editor/user-avatars.tsx import { useOthers, useSelf } from '@liveblocks/react/suspense'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '../ui/tooltip'; export function Avatars() { const users = useOthers(); const currentUser = useSelf(); return ( <div className="flex py-[0.75rem]"> {users.map(({ connectionId, info }) => { return ( <Avatar key={connectionId} name={info.name} color={info.color} /> ); })} {currentUser && ( <div className="relative ml-8 first:ml-0"> <Avatar color={currentUser.info.color} name={currentUser.info.name} /> </div> )} </div> ); } export function Avatar({ name, color }: { name: string; color: string }) { return ( <TooltipProvider> <Tooltip> <TooltipTrigger> <div className="flex place-content-center relative border-4 border-white rounded-full w-[42px] h-[42px] bg-[#9ca3af] ml-[-0.75rem]"> <div className="w-full h-full rounded-full flex items-center justify-center text-white" style={{ background: color }} > {name.slice(0, 2).toUpperCase()} </div> </div> </TooltipTrigger> <TooltipContent>{name}</TooltipContent> </Tooltip> </TooltipProvider> ); } Next, we will need a toolbar for showing different formatting options in our editor. it will have formatting options like bold, italic, strike, list and so on. // components/editor/icons.tsx import React from 'react'; export const BoldIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M18.25 25H9V7H17.5C18.5022 7.00006 19.4834 7.28695 20.3277 7.82679C21.172 8.36662 21.8442 9.13684 22.2649 10.0465C22.6855 10.9561 22.837 11.9671 22.7015 12.96C22.5659 13.953 22.149 14.8864 21.5 15.65C22.3477 16.328 22.9645 17.252 23.2653 18.295C23.5662 19.3379 23.5364 20.4485 23.18 21.4738C22.8236 22.4991 22.1581 23.3887 21.2753 24.0202C20.3924 24.6517 19.3355 24.994 18.25 25ZM12 22H18.23C18.5255 22 18.8181 21.9418 19.091 21.8287C19.364 21.7157 19.6121 21.5499 19.821 21.341C20.0299 21.1321 20.1957 20.884 20.3087 20.611C20.4218 20.3381 20.48 20.0455 20.48 19.75C20.48 19.4545 20.4218 19.1619 20.3087 18.889C20.1957 18.616 20.0299 18.3679 19.821 18.159C19.6121 17.9501 19.364 17.7843 19.091 17.6713C18.8181 17.5582 18.5255 17.5 18.23 17.5H12V22ZM12 14.5H17.5C17.7955 14.5 18.0881 14.4418 18.361 14.3287C18.634 14.2157 18.8821 14.0499 19.091 13.841C19.2999 13.6321 19.4657 13.384 19.5787 13.111C19.6918 12.8381 19.75 12.5455 19.75 12.25C19.75 11.9545 19.6918 11.6619 19.5787 11.389C19.4657 11.116 19.2999 10.8679 19.091 10.659C18.8821 10.4501 18.634 10.2843 18.361 10.1713C18.0881 10.0582 17.7955 10 17.5 10H12V14.5Z" fill="currentColor" /> </svg> ); export const ItalicIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M25 9V7H12V9H17.14L12.77 23H7V25H20V23H14.86L19.23 9H25Z" fill="currentColor" /> </svg> ); export const StrikethroughIcon = () => ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M17.1538 14C17.3846 14.5161 17.5 15.0893 17.5 15.7196C17.5 17.0625 16.9762 18.1116 15.9286 18.867C14.8809 19.6223 13.4335 20 11.5862 20C9.94674 20 8.32335 19.6185 6.71592 18.8555V16.6009C8.23538 17.4783 9.7908 17.917 11.3822 17.917C13.9333 17.917 15.2128 17.1846 15.2208 15.7196C15.2208 15.0939 15.0049 14.5598 14.5731 14.1173C14.5339 14.0772 14.4939 14.0381 14.4531 14H3V12H21V14H17.1538ZM13.076 11H7.62908C7.4566 10.8433 7.29616 10.6692 7.14776 10.4778C6.71592 9.92084 6.5 9.24559 6.5 8.45207C6.5 7.21602 6.96583 6.165 7.89749 5.299C8.82916 4.43299 10.2706 4 12.2219 4C13.6934 4 15.1009 4.32808 16.4444 4.98426V7.13591C15.2448 6.44921 13.9293 6.10587 12.4978 6.10587C10.0187 6.10587 8.77917 6.88793 8.77917 8.45207C8.77917 8.87172 8.99709 9.23796 9.43293 9.55079C9.86878 9.86362 10.4066 10.1135 11.0463 10.3004C11.6665 10.4816 12.3431 10.7148 13.076 11H13.076Z" fill="currentColor" ></path> </svg> ); export const BlockQuoteIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M11.5 13.375H7.81875C7.93491 12.6015 8.21112 11.8607 8.62974 11.1999C9.04837 10.5391 9.6002 9.97295 10.25 9.5375L11.3688 8.7875L10.6812 7.75L9.5625 8.5C8.6208 9.12755 7.84857 9.97785 7.31433 10.9755C6.7801 11.9731 6.50038 13.0871 6.5 14.2188V18.375C6.5 18.7065 6.6317 19.0245 6.86612 19.2589C7.10054 19.4933 7.41848 19.625 7.75 19.625H11.5C11.8315 19.625 12.1495 19.4933 12.3839 19.2589C12.6183 19.0245 12.75 18.7065 12.75 18.375V14.625C12.75 14.2935 12.6183 13.9755 12.3839 13.7411C12.1495 13.5067 11.8315 13.375 11.5 13.375ZM20.25 13.375H16.5688C16.6849 12.6015 16.9611 11.8607 17.3797 11.1999C17.7984 10.5391 18.3502 9.97295 19 9.5375L20.1188 8.7875L19.4375 7.75L18.3125 8.5C17.3708 9.12755 16.5986 9.97785 16.0643 10.9755C15.5301 11.9731 15.2504 13.0871 15.25 14.2188V18.375C15.25 18.7065 15.3817 19.0245 15.6161 19.2589C15.8505 19.4933 16.1685 19.625 16.5 19.625H20.25C20.5815 19.625 20.8995 19.4933 21.1339 19.2589C21.3683 19.0245 21.5 18.7065 21.5 18.375V14.625C21.5 14.2935 21.3683 13.9755 21.1339 13.7411C20.8995 13.5067 20.5815 13.375 20.25 13.375Z" fill="currentColor" /> </svg> ); export const HorizontalLineIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <rect x="6.5" y="13.375" width="15" height="1.25" fill="currentColor" /> </svg> ); export const OrderedListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9ZM9 11.5V6.5H7.75V7.125H6.5V8.375H7.75V11.5H6.5V12.75H10.25V11.5H9ZM10.25 21.5H6.5V19C6.5 18.6685 6.6317 18.3505 6.86612 18.1161C7.10054 17.8817 7.41848 17.75 7.75 17.75H9V16.5H6.5V15.25H9C9.33152 15.25 9.64946 15.3817 9.88388 15.6161C10.1183 15.8505 10.25 16.1685 10.25 16.5V17.75C10.25 18.0815 10.1183 18.3995 9.88388 18.6339C9.64946 18.8683 9.33152 19 9 19H7.75V20.25H10.25V21.5Z" fill="currentColor" /> </svg> ); export const BulletListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M8.375 11.5C9.41053 11.5 10.25 10.6605 10.25 9.625C10.25 8.58947 9.41053 7.75 8.375 7.75C7.33947 7.75 6.5 8.58947 6.5 9.625C6.5 10.6605 7.33947 11.5 8.375 11.5Z" fill="currentColor" /> <path d="M8.375 20.25C9.41053 20.25 10.25 19.4105 10.25 18.375C10.25 17.3395 9.41053 16.5 8.375 16.5C7.33947 16.5 6.5 17.3395 6.5 18.375C6.5 19.4105 7.33947 20.25 8.375 20.25Z" fill="currentColor" /> <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9Z" fill="currentColor" /> </svg> ); // components/editor/icons.tsx import React from 'react'; export const BoldIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M18.25 25H9V7H17.5C18.5022 7.00006 19.4834 7.28695 20.3277 7.82679C21.172 8.36662 21.8442 9.13684 22.2649 10.0465C22.6855 10.9561 22.837 11.9671 22.7015 12.96C22.5659 13.953 22.149 14.8864 21.5 15.65C22.3477 16.328 22.9645 17.252 23.2653 18.295C23.5662 19.3379 23.5364 20.4485 23.18 21.4738C22.8236 22.4991 22.1581 23.3887 21.2753 24.0202C20.3924 24.6517 19.3355 24.994 18.25 25ZM12 22H18.23C18.5255 22 18.8181 21.9418 19.091 21.8287C19.364 21.7157 19.6121 21.5499 19.821 21.341C20.0299 21.1321 20.1957 20.884 20.3087 20.611C20.4218 20.3381 20.48 20.0455 20.48 19.75C20.48 19.4545 20.4218 19.1619 20.3087 18.889C20.1957 18.616 20.0299 18.3679 19.821 18.159C19.6121 17.9501 19.364 17.7843 19.091 17.6713C18.8181 17.5582 18.5255 17.5 18.23 17.5H12V22ZM12 14.5H17.5C17.7955 14.5 18.0881 14.4418 18.361 14.3287C18.634 14.2157 18.8821 14.0499 19.091 13.841C19.2999 13.6321 19.4657 13.384 19.5787 13.111C19.6918 12.8381 19.75 12.5455 19.75 12.25C19.75 11.9545 19.6918 11.6619 19.5787 11.389C19.4657 11.116 19.2999 10.8679 19.091 10.659C18.8821 10.4501 18.634 10.2843 18.361 10.1713C18.0881 10.0582 17.7955 10 17.5 10H12V14.5Z" fill="currentColor" /> </svg> ); export const ItalicIcon = () => ( <svg width="16" height="16" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M25 9V7H12V9H17.14L12.77 23H7V25H20V23H14.86L19.23 9H25Z" fill="currentColor" /> </svg> ); export const StrikethroughIcon = () => ( <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" > <path d="M17.1538 14C17.3846 14.5161 17.5 15.0893 17.5 15.7196C17.5 17.0625 16.9762 18.1116 15.9286 18.867C14.8809 19.6223 13.4335 20 11.5862 20C9.94674 20 8.32335 19.6185 6.71592 18.8555V16.6009C8.23538 17.4783 9.7908 17.917 11.3822 17.917C13.9333 17.917 15.2128 17.1846 15.2208 15.7196C15.2208 15.0939 15.0049 14.5598 14.5731 14.1173C14.5339 14.0772 14.4939 14.0381 14.4531 14H3V12H21V14H17.1538ZM13.076 11H7.62908C7.4566 10.8433 7.29616 10.6692 7.14776 10.4778C6.71592 9.92084 6.5 9.24559 6.5 8.45207C6.5 7.21602 6.96583 6.165 7.89749 5.299C8.82916 4.43299 10.2706 4 12.2219 4C13.6934 4 15.1009 4.32808 16.4444 4.98426V7.13591C15.2448 6.44921 13.9293 6.10587 12.4978 6.10587C10.0187 6.10587 8.77917 6.88793 8.77917 8.45207C8.77917 8.87172 8.99709 9.23796 9.43293 9.55079C9.86878 9.86362 10.4066 10.1135 11.0463 10.3004C11.6665 10.4816 12.3431 10.7148 13.076 11H13.076Z" fill="currentColor" ></path> </svg> ); export const BlockQuoteIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M11.5 13.375H7.81875C7.93491 12.6015 8.21112 11.8607 8.62974 11.1999C9.04837 10.5391 9.6002 9.97295 10.25 9.5375L11.3688 8.7875L10.6812 7.75L9.5625 8.5C8.6208 9.12755 7.84857 9.97785 7.31433 10.9755C6.7801 11.9731 6.50038 13.0871 6.5 14.2188V18.375C6.5 18.7065 6.6317 19.0245 6.86612 19.2589C7.10054 19.4933 7.41848 19.625 7.75 19.625H11.5C11.8315 19.625 12.1495 19.4933 12.3839 19.2589C12.6183 19.0245 12.75 18.7065 12.75 18.375V14.625C12.75 14.2935 12.6183 13.9755 12.3839 13.7411C12.1495 13.5067 11.8315 13.375 11.5 13.375ZM20.25 13.375H16.5688C16.6849 12.6015 16.9611 11.8607 17.3797 11.1999C17.7984 10.5391 18.3502 9.97295 19 9.5375L20.1188 8.7875L19.4375 7.75L18.3125 8.5C17.3708 9.12755 16.5986 9.97785 16.0643 10.9755C15.5301 11.9731 15.2504 13.0871 15.25 14.2188V18.375C15.25 18.7065 15.3817 19.0245 15.6161 19.2589C15.8505 19.4933 16.1685 19.625 16.5 19.625H20.25C20.5815 19.625 20.8995 19.4933 21.1339 19.2589C21.3683 19.0245 21.5 18.7065 21.5 18.375V14.625C21.5 14.2935 21.3683 13.9755 21.1339 13.7411C20.8995 13.5067 20.5815 13.375 20.25 13.375Z" fill="currentColor" /> </svg> ); export const HorizontalLineIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <rect x="6.5" y="13.375" width="15" height="1.25" fill="currentColor" /> </svg> ); export const OrderedListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9ZM9 11.5V6.5H7.75V7.125H6.5V8.375H7.75V11.5H6.5V12.75H10.25V11.5H9ZM10.25 21.5H6.5V19C6.5 18.6685 6.6317 18.3505 6.86612 18.1161C7.10054 17.8817 7.41848 17.75 7.75 17.75H9V16.5H6.5V15.25H9C9.33152 15.25 9.64946 15.3817 9.88388 15.6161C10.1183 15.8505 10.25 16.1685 10.25 16.5V17.75C10.25 18.0815 10.1183 18.3995 9.88388 18.6339C9.64946 18.8683 9.33152 19 9 19H7.75V20.25H10.25V21.5Z" fill="currentColor" /> </svg> ); export const BulletListIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" > <path d="M8.375 11.5C9.41053 11.5 10.25 10.6605 10.25 9.625C10.25 8.58947 9.41053 7.75 8.375 7.75C7.33947 7.75 6.5 8.58947 6.5 9.625C6.5 10.6605 7.33947 11.5 8.375 11.5Z" fill="currentColor" /> <path d="M8.375 20.25C9.41053 20.25 10.25 19.4105 10.25 18.375C10.25 17.3395 9.41053 16.5 8.375 16.5C7.33947 16.5 6.5 17.3395 6.5 18.375C6.5 19.4105 7.33947 20.25 8.375 20.25Z" fill="currentColor" /> <path d="M14 17.75H22.75V19H14V17.75ZM14 9H22.75V10.25H14V9Z" fill="currentColor" /> </svg> ); // components/editor/toolbar.tsx import { Editor } from '@tiptap/react'; import { BoldIcon, ItalicIcon, StrikethroughIcon, BlockQuoteIcon, HorizontalLineIcon, BulletListIcon, OrderedListIcon, } from './icons'; import { cn } from '@/lib/utils'; type Props = { editor: Editor | null; }; type ButtonProps = { editor: Editor; isActive: boolean; ariaLabel: string; Icon: React.FC; onClick: () => void; }; const ToolbarButton = ({ isActive, ariaLabel, Icon, onClick }: ButtonProps) => ( <button className={cn( 'flex items-center justify-center w-8 h-8 border border-gray-200 rounded', { 'bg-gray-100': isActive, } )} onClick={onClick} data-active={isActive ? 'is-active' : undefined} aria-label={ariaLabel} > <Icon /> </button> ); export function Toolbar({ editor }: Props) { if (!editor) { return null; } return ( <div className="flex gap-4"> <ToolbarButton editor={editor} isActive={editor.isActive('bold')} ariaLabel="bold" Icon={BoldIcon} onClick={() => editor.chain().focus().toggleBold().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('italic')} ariaLabel="italic" Icon={ItalicIcon} onClick={() => editor.chain().focus().toggleItalic().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('strike')} ariaLabel="strikethrough" Icon={StrikethroughIcon} onClick={() => editor.chain().focus().toggleStrike().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('blockquote')} ariaLabel="blockquote" Icon={BlockQuoteIcon} onClick={() => editor.chain().focus().toggleBlockquote().run()} /> <ToolbarButton editor={editor} isActive={false} ariaLabel="horizontal-line" Icon={HorizontalLineIcon} onClick={() => editor.chain().focus().setHorizontalRule().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('bulletList')} ariaLabel="bullet-list" Icon={BulletListIcon} onClick={() => editor.chain().focus().toggleBulletList().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('orderedList')} ariaLabel="number-list" Icon={OrderedListIcon} onClick={() => editor.chain().focus().toggleOrderedList().run()} /> </div> ); } // components/editor/toolbar.tsx import { Editor } from '@tiptap/react'; import { BoldIcon, ItalicIcon, StrikethroughIcon, BlockQuoteIcon, HorizontalLineIcon, BulletListIcon, OrderedListIcon, } from './icons'; import { cn } from '@/lib/utils'; type Props = { editor: Editor | null; }; type ButtonProps = { editor: Editor; isActive: boolean; ariaLabel: string; Icon: React.FC; onClick: () => void; }; const ToolbarButton = ({ isActive, ariaLabel, Icon, onClick }: ButtonProps) => ( <button className={cn( 'flex items-center justify-center w-8 h-8 border border-gray-200 rounded', { 'bg-gray-100': isActive, } )} onClick={onClick} data-active={isActive ? 'is-active' : undefined} aria-label={ariaLabel} > <Icon /> </button> ); export function Toolbar({ editor }: Props) { if (!editor) { return null; } return ( <div className="flex gap-4"> <ToolbarButton editor={editor} isActive={editor.isActive('bold')} ariaLabel="bold" Icon={BoldIcon} onClick={() => editor.chain().focus().toggleBold().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('italic')} ariaLabel="italic" Icon={ItalicIcon} onClick={() => editor.chain().focus().toggleItalic().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('strike')} ariaLabel="strikethrough" Icon={StrikethroughIcon} onClick={() => editor.chain().focus().toggleStrike().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('blockquote')} ariaLabel="blockquote" Icon={BlockQuoteIcon} onClick={() => editor.chain().focus().toggleBlockquote().run()} /> <ToolbarButton editor={editor} isActive={false} ariaLabel="horizontal-line" Icon={HorizontalLineIcon} onClick={() => editor.chain().focus().setHorizontalRule().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('bulletList')} ariaLabel="bullet-list" Icon={BulletListIcon} onClick={() => editor.chain().focus().toggleBulletList().run()} /> <ToolbarButton editor={editor} isActive={editor.isActive('orderedList')} ariaLabel="number-list" Icon={OrderedListIcon} onClick={() => editor.chain().focus().toggleOrderedList().run()} /> </div> ); } Now, let’s use Tiptap and Liveblocks together to create our collaborative editor component. Tiptap Liveblocks Liveblocks uses rooms to let users work together. Each room can have its own permissions and details. For real-time collaboration, we will use the useRoom hook and LiveblocksYjsProvider with Tiptap's Collaboration and CollaborationCursor extensions. It takes one prop, isReadOnly , to decide if users can edit the document or not. useRoom LiveblocksYjsProvider Collaboration CollaborationCursor isReadOnly // components/editor/collaborative-editor.tsx 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import * as Y from 'yjs'; import { LiveblocksYjsProvider } from '@liveblocks/yjs'; import { useRoom, useSelf } from '@liveblocks/react/suspense'; import { useEffect, useState } from 'react'; import { Toolbar } from './toolbar'; import { Avatars } from './user-avatars'; export function CollaborativeEditor({ isReadOnly }: { isReadOnly: boolean }) { const room = useRoom(); const [doc, setDoc] = useState<Y.Doc>(); const [provider, setProvider] = useState<LiveblocksYjsProvider>(); useEffect(() => { const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc); setDoc(yDoc); setProvider(yProvider); return () => { yDoc?.destroy(); yProvider?.destroy(); }; }, [room]); if (!doc || !provider) { return null; } return <TiptapEditor isReadOnly={isReadOnly} doc={doc} provider={provider} />; } function TiptapEditor({ doc, provider, isReadOnly, }: { doc: Y.Doc; provider: LiveblocksYjsProvider; isReadOnly: boolean; }) { const userInfo = useSelf((me) => me.info); const editor = useEditor({ editorProps: { attributes: { class: 'flex-grow w-full h-full pt-4 focus:outline-none', }, editable: () => !isReadOnly, }, extensions: [ StarterKit.configure({ history: false, }), Collaboration.configure({ document: doc, }), CollaborationCursor.configure({ provider: provider, user: userInfo, }), ], }); return ( <div className="flex flex-col bg-white w-full h-full"> <div className="flex justify-between items-center"> <Toolbar editor={editor} /> <Avatars /> </div> <EditorContent readOnly={isReadOnly} editor={editor} className="relative h-full" /> </div> ); } // components/editor/collaborative-editor.tsx 'use client'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import * as Y from 'yjs'; import { LiveblocksYjsProvider } from '@liveblocks/yjs'; import { useRoom, useSelf } from '@liveblocks/react/suspense'; import { useEffect, useState } from 'react'; import { Toolbar } from './toolbar'; import { Avatars } from './user-avatars'; export function CollaborativeEditor({ isReadOnly }: { isReadOnly: boolean }) { const room = useRoom(); const [doc, setDoc] = useState<Y.Doc>(); const [provider, setProvider] = useState<LiveblocksYjsProvider>(); useEffect(() => { const yDoc = new Y.Doc(); const yProvider = new LiveblocksYjsProvider(room, yDoc); setDoc(yDoc); setProvider(yProvider); return () => { yDoc?.destroy(); yProvider?.destroy(); }; }, [room]); if (!doc || !provider) { return null; } return <TiptapEditor isReadOnly={isReadOnly} doc={doc} provider={provider} />; } function TiptapEditor({ doc, provider, isReadOnly, }: { doc: Y.Doc; provider: LiveblocksYjsProvider; isReadOnly: boolean; }) { const userInfo = useSelf((me) => me.info); const editor = useEditor({ editorProps: { attributes: { class: 'flex-grow w-full h-full pt-4 focus:outline-none', }, editable: () => !isReadOnly, }, extensions: [ StarterKit.configure({ history: false, }), Collaboration.configure({ document: doc, }), CollaborationCursor.configure({ provider: provider, user: userInfo, }), ], }); return ( <div className="flex flex-col bg-white w-full h-full"> <div className="flex justify-between items-center"> <Toolbar editor={editor} /> <Avatars /> </div> <EditorContent readOnly={isReadOnly} editor={editor} className="relative h-full" /> </div> ); } As we discussed, Liveblocks uses rooms for collaboration, so we need a way to create a Liveblock session with permissions for the active room. This way, each user in the room can have their unique identity and permissions. Create app/api/liveblock-session/route.ts and add the following code. app/api/liveblock-session/route.ts import { LIVEBLOCKS_CLIENT } from '@/lib/liveblocks'; import { NextRequest } from 'next/server'; function generateRandomHexColor() { const randomColor = Math.floor(Math.random() * 16777215).toString(16); return `#${randomColor.padStart(6, '0')}`; } export async function POST(request: NextRequest) { const { user, roomId, permissions } = await request.json(); const allowedPermission: ('room:read' | 'room:write')[] = []; const session = LIVEBLOCKS_CLIENT.prepareSession(user.$id, { userInfo: { name: user.name, color: generateRandomHexColor(), }, }); if (permissions.read) { allowedPermission.push('room:read'); } if (permissions.update) { allowedPermission.push('room:write'); } session.allow(roomId!, allowedPermission); const { body, status } = await session.authorize(); return new Response(body, { status }); } import { LIVEBLOCKS_CLIENT } from '@/lib/liveblocks'; import { NextRequest } from 'next/server'; function generateRandomHexColor() { const randomColor = Math.floor(Math.random() * 16777215).toString(16); return `#${randomColor.padStart(6, '0')}`; } export async function POST(request: NextRequest) { const { user, roomId, permissions } = await request.json(); const allowedPermission: ('room:read' | 'room:write')[] = []; const session = LIVEBLOCKS_CLIENT.prepareSession(user.$id, { userInfo: { name: user.name, color: generateRandomHexColor(), }, }); if (permissions.read) { allowedPermission.push('room:read'); } if (permissions.update) { allowedPermission.push('room:write'); } session.allow(roomId!, allowedPermission); const { body, status } = await session.authorize(); return new Response(body, { status }); } Here, we are creating a POST route to set up a liveblock session. In the request, we get user details, the active roomId, and permissions (from Permit.io , which we’ll discuss soon). We use the Liveblocks client’s prepareSession method to create the session with the user's unique ID and some extra information (used to display the live cursor and user avatars in the editor). POST Permit.io prepareSession We then check if the user has read or update permissions and add the appropriate room permission, either room:read or room:write . Finally, we call the session.allow method with the roomId and permissions list, and then authorize it to generate a unique token. room:read room:write session.allow For userInfo, we can only pass the name and color attributes because we have defined only those attributes in liveblocks.config.ts. For userInfo, we can only pass the name and color attributes because we have defined only those attributes in liveblocks.config.ts. For userInfo , we can only pass the name and color attributes because we have defined only those attributes in liveblocks.config.ts. userInfo Alright, now let’s create a LiveblocksWrapper component and use the endpoint we made. LiveblocksWrapper // components/editor/liveblocks-wrapper.tsx 'use client'; import { ClientSideSuspense, LiveblocksProvider, RoomProvider, } from '@liveblocks/react/suspense'; import Loader from '../loader'; import { PermissionType } from '@/app/actions'; import { useAuthStore } from '@/store/authStore'; interface LiveblocksWrapperProps { children: React.ReactNode; roomId: string; permissions: Record<PermissionType, boolean>; } export default function LiveblocksWrapper({ children, roomId, permissions, }: Readonly<LiveblocksWrapperProps>) { const { user } = useAuthStore(); return ( <LiveblocksProvider authEndpoint={async (room) => { const response = await fetch('/api/liveblock-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user: user, roomId: roomId, room, permissions, }), }); return await response.json(); }} > <RoomProvider id={roomId} initialPresence={{ cursor: null, }} > <ClientSideSuspense fallback={<Loader />}> {children} </ClientSideSuspense> </RoomProvider> </LiveblocksProvider> ); } // components/editor/liveblocks-wrapper.tsx 'use client'; import { ClientSideSuspense, LiveblocksProvider, RoomProvider, } from '@liveblocks/react/suspense'; import Loader from '../loader'; import { PermissionType } from '@/app/actions'; import { useAuthStore } from '@/store/authStore'; interface LiveblocksWrapperProps { children: React.ReactNode; roomId: string; permissions: Record<PermissionType, boolean>; } export default function LiveblocksWrapper({ children, roomId, permissions, }: Readonly<LiveblocksWrapperProps>) { const { user } = useAuthStore(); return ( <LiveblocksProvider authEndpoint={async (room) => { const response = await fetch('/api/liveblock-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ user: user, roomId: roomId, room, permissions, }), }); return await response.json(); }} > <RoomProvider id={roomId} initialPresence={{ cursor: null, }} > <ClientSideSuspense fallback={<Loader />}> {children} </ClientSideSuspense> </RoomProvider> </LiveblocksProvider> ); } Here, we use the LiveblocksProvider from the Liveblocks package, which takes authEndpoint as a prop. We call the '/api/liveblock-session' endpoint with the user, roomId, and permissions. Next, we use RoomProvider to create a separate space for collaboration. It takes two props: id (roomId) and initialPresence (to show the active user's cursor in the editor). LiveblocksProvider authEndpoint '/api/liveblock-session' RoomProvider id initialPresence ClientSideSuspense is used to display a fallback until the room is ready. ClientSideSuspense Great, we have the LiveblocksWrapper ready. Now, let’s use it on the document page. But before that, let’s add an authorization check on our document page by getting the permissions using permit.io server actions. // app/document/[id]/page.tsx ... import { getResourcePermissions, PermissionType } from "@/app/actions"; import { Button } from "@/components/ui/button"; import { useAuthStore } from "@/store/authStore"; import { useRouter } from "next/navigation"; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); ... const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ["read", "update", "delete"], resource_instance: `document:${params.id}`, user: user?.email ?? "", }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); ... if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? "/dashboard" : `/login?next=/document/${params.id}`, ) } > {user ? "Dashboard" : "Login"} </Button> </section> ); } .... } // app/document/[id]/page.tsx ... import { getResourcePermissions, PermissionType } from "@/app/actions"; import { Button } from "@/components/ui/button"; import { useAuthStore } from "@/store/authStore"; import { useRouter } from "next/navigation"; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); ... const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ["read", "update", "delete"], resource_instance: `document:${params.id}`, user: user?.email ?? "", }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); ... if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? "/dashboard" : `/login?next=/document/${params.id}`, ) } > {user ? "Dashboard" : "Login"} </Button> </section> ); } .... } Here, we are updating the document page by adding a new method called fetchPermissions . This method runs first to check if the current user has permission, specifically read permission, before fetching the document content. If the user doesn't have permission, it shows a message saying they can't view the document. fetchPermissions The final code for the document page will include a permission check, and the content will be wrapped with the LiveBlocks provider. 'use client'; import { getResourcePermissions, PermissionType } from '@/app/actions'; import { Document } from '@/app/dashboard/page'; import { DeleteDocument } from '@/components/delete-document'; import { CollaborativeEditor } from '@/components/editor/collaborative-editor'; import LiveblocksWrapper from '@/components/editor/liveblocks-wrapper'; import Loader from '@/components/loader'; import { ShareDocument } from '@/components/share-document'; import { Button } from '@/components/ui/button'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useAuthStore } from '@/store/authStore'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); const [isLoading, setIsLoading] = useState(true); const [document, setDocument] = useState<Document | null>(null); const fetchDocument = async () => { try { const document = await APPWRITE_CLIENT.databases.getDocument<Document>( '66fad2d0001b08997cb9', '66fad37e0033c987cf4d', params.id ); setDocument(document); } catch (error) { console.error(error); } finally { setIsLoading(false); } }; const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ['read', 'update', 'delete'], resource_instance: `document:${params.id}`, user: user?.email ?? '', }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); if (isLoading) { return <Loader />; } if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? '/dashboard' : `/login?next=/document/${params.id}` ) } > {user ? 'Dashboard' : 'Login'} </Button> </section> ); } if (!document) { return ( <section className="h-screen flex justify-center items-center"> <p>Document not found</p> </section> ); } return ( <LiveblocksWrapper permissions={permissions} roomId={document.roomId}> <div className="container mx-auto px-4 py-8"> <div className="flex justify-between items-center mb-8"> <h1 className="text-3xl font-bold">{document.title}</h1> <div className="flex gap-4"> <ShareDocument permission={permissions.update} documentId={params.id} /> <DeleteDocument documentId={params.id} permission={permissions.delete} /> </div> </div> <CollaborativeEditor isReadOnly={!permissions.update} /> </div> </LiveblocksWrapper> ); } 'use client'; import { getResourcePermissions, PermissionType } from '@/app/actions'; import { Document } from '@/app/dashboard/page'; import { DeleteDocument } from '@/components/delete-document'; import { CollaborativeEditor } from '@/components/editor/collaborative-editor'; import LiveblocksWrapper from '@/components/editor/liveblocks-wrapper'; import Loader from '@/components/loader'; import { ShareDocument } from '@/components/share-document'; import { Button } from '@/components/ui/button'; import { APPWRITE_CLIENT } from '@/lib/appwrite'; import { useAuthStore } from '@/store/authStore'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; export default function DocumentPage({ params }: { params: { id: string } }) { const { user } = useAuthStore(); const router = useRouter(); const [permissions, setPermissions] = useState<Record<PermissionType, boolean>>(); const [isLoading, setIsLoading] = useState(true); const [document, setDocument] = useState<Document | null>(null); const fetchDocument = async () => { try { const document = await APPWRITE_CLIENT.databases.getDocument<Document>( '66fad2d0001b08997cb9', '66fad37e0033c987cf4d', params.id ); setDocument(document); } catch (error) { console.error(error); } finally { setIsLoading(false); } }; const fetchPermissions = async () => { setIsLoading(true); const isPermitted = await getResourcePermissions({ permissions: ['read', 'update', 'delete'], resource_instance: `document:${params.id}`, user: user?.email ?? '', }); setPermissions(isPermitted); if (isPermitted.read) { fetchDocument(); } else { setIsLoading(false); } }; useEffect(() => { fetchPermissions(); }, []); if (isLoading) { return <Loader />; } if (!permissions?.read || !user) { return ( <section className="h-screen flex justify-center items-center flex-col gap-4"> <p>You do not have permission to view this document</p> <Button onClick={() => router.push( user ? '/dashboard' : `/login?next=/document/${params.id}` ) } > {user ? 'Dashboard' : 'Login'} </Button> </section> ); } if (!document) { return ( <section className="h-screen flex justify-center items-center"> <p>Document not found</p> </section> ); } return ( <LiveblocksWrapper permissions={permissions} roomId={document.roomId}> <div className="container mx-auto px-4 py-8"> <div className="flex justify-between items-center mb-8"> <h1 className="text-3xl font-bold">{document.title}</h1> <div className="flex gap-4"> <ShareDocument permission={permissions.update} documentId={params.id} /> <DeleteDocument documentId={params.id} permission={permissions.delete} /> </div> </div> <CollaborativeEditor isReadOnly={!permissions.update} /> </div> </LiveblocksWrapper> ); } Component Start When the component starts, it first checks if a user is logged in. This makes sure only authorized users can see and use the document. User Authentication Check If the user is logged in, the component gets the permissions needed for the document. If no user is logged in, it sends them to the login page to stop unauthorized access. If the user is logged in, the component gets the permissions needed for the document. If no user is logged in, it sends them to the login page to stop unauthorized access. Get Permissions The component asks for permissions for three main actions: read , update , and delete . Based on the response: read update delete If read permission is given, the component tries to get the document. If read permission is not given, it shows a message: “You do not have permission to view this document.” If read permission is given, the component tries to get the document. read If read permission is not given, it shows a message: “You do not have permission to view this document.” read “You do not have permission to view this document.” Get Document If the component has the right permissions, it calls an API to get the document by its ID. If the document is found, it shows the content. If the document is not found or the ID is wrong, it shows a message: “Document not found.” If the document is found, it shows the content. If the document is not found or the ID is wrong, it shows a message: “Document not found.” “Document not found.” Show Document After getting the document, the component shows the document’s title and includes a collaborative editor. Depending on the permissions: If the user has update permissions, the editor can be used to edit. If the user does not have update permissions, the editor is in read-only mode, letting users see but not change the content. If the user has update permissions, the editor can be used to edit. update If the user does not have update permissions, the editor is in read-only mode, letting users see but not change the content. update Show Options The component shows extra options based on update and delete permissions: update delete Share Option: Available if the user has update permissions. Delete Option: Available if the user has delete permissions. Share Option : Available if the user has update permissions. Share Option update Delete Option : Available if the user has delete permissions. Delete Option delete These options let users share the document with others or delete it if necessary. Testing Demo Let’s test what we have built. First, create three new users using the register function: Owner: owner@gmail.com Editor: editor@gmail.com Viewer: viewer@gmail.com Owner: owner@gmail.com Owner: owner@gmail.com owner@gmail.com Editor: editor@gmail.com Editor: editor@gmail.com editor@gmail.com Viewer: viewer@gmail.com Viewer: viewer@gmail.com viewer@gmail.com Then, log in with the Owner’s credentials and create a document titled “Testing ReBAC on document with Permit.io ”. Permit.io Permit.io Permit.io Go to the page of the created document and add some content. Next, click the share button and share the article with the other two users: Editor and Owner, assigning them the editor and owner roles, respectively. editor owner Now log in with three different users in three separate windows and watch it in action: live cursor, connected user avatars, and live editing. See, the owner has full access to share, edit, and delete the document. The editor can only edit and share the document, while the reader can only view it, with no ability to share or delete it. https://youtu.be/z_5AMOzKknQ?embedable=true https://youtu.be/z_5AMOzKknQ?embedable=true Conclusion In this article, we’ve created a secure, real-time document editor using Next.js, Appwrite , Liveblocks , and Permit.io with ReBAC. Appwrite Appwrite Liveblocks Liveblocks Permit.io Permit.io This setup allows for": Real-time collaboration with presence awareness Secure login and document storage Detailed, relationship-based access control Real-time collaboration with presence awareness Real-time collaboration with presence awareness Secure login and document storage Secure login and document storage Detailed, relationship-based access control Detailed, relationship-based access control Using ReBAC through Permit.io , we’ve built a flexible permission system that can handle complex access needs. This ensures that document access and editing rights are managed securely and efficiently, even as your app grows. Permit.io Permit.io Keep in mind that making a production-ready collaborative editor involves more, like handling errors, making updates quickly, and resolving conflicts. However, this setup gives you a strong base for building advanced collaborative tools with good security. Resources Find all the code files of this project in this GitHub Repo Learn more about ReBAC by Permit.io Want to learn more about implementing authorization? Got questions? Reach out to Permit.io Slack community! Find all the code files of this project in this GitHub Repo GitHub Repo GitHub Repo Learn more about ReBAC by Permit.io ReBAC by Permit.io ReBAC by Permit.io Want to learn more about implementing authorization? Got questions? Reach out to Permit.io Slack community ! Slack community Slack community That’s all for now. Thank you for reading!