As modern chat applications evolve, real-time communication requires increasingly granular access controls. Managing dynamic permissions in real-time for various chat rooms and participants, especially in complex or multi-user environments, can quickly become challenging. What if you could easily implement fine-grained authorization for your chat app without compromising on performance?
Permit.io makes integrating robust, real-time access control into your chat application simple. By pairing Permit.io’s advanced authorization models with WebSockets, you can ensure that the right users have access at the right time, all while maintaining the responsiveness needed in chat applications.
In this tutorial, you’ll learn how to implement real-time authorization in a WebSocket-based chat application using Permit.io. By the end, you’ll understand how to enforce role-based and attribute-based access controls dynamically, securing different chat rooms, message visibility, and interactions in real time.
Introduction to Real-Time Authorization in Chat Applications
We all use chat applications in one form or another to stay connected with friends and family, discuss important matters with colleagues, and even conduct business. With the increasing demand for seamless, real-time communication, it’s easy to take for granted the sophisticated security measures that protect these interactions. However, as chat applications become more complex, so do the challenges of securing user data and conversations. Fine-grained access control helps ensure that only authorized users have access to sensitive information and actions.
Why real-time chat apps require fine-grained access control
Fine-grained access control is essential in real-time chat applications to ensure security, user customization, and regulatory compliance.
By setting robust authentication methods and role-based permissions, chat applications prevent unauthorized users from accessing sensitive conversations and allow admins to effectively manage user interactions. This approach also enhances user experience by enabling participation in various chat types—public, private, or group—based on individual roles or preferences, creating more engaging interactions.
Furthermore, fine-grained access control helps organizations meet strict data privacy regulations, like the GDPR, safeguarding confidential data and minimizing legal risks.
The challenges of implementing dynamic authorization in a chat context
The points cover all the main ideas from the paragraphs. Here’s a refined version that includes every detail:
- Real-time chat apps require instant permission checks and updates, making dynamic authorization challenging without risking performance impacts, especially when handling large volumes of messages and users.
- Chat applications often involve multiple access layers, with permissions that vary based on roles, group memberships, or specific attributes, requiring consistent and efficient enforcement.
- Dynamic changes in roles (e.g., admin promotions, group removals, or temporary access) must be recognized and applied immediately across all active sessions without disrupting ongoing conversations.
- Achieving this level of flexibility while maintaining a seamless user experience demands an advanced authorization model that integrates closely with real-time protocols like WebSockets.
Overview of how Permit.io’s authorization solutions can streamline this process with WebSockets
Permit.io’s authorization solutions can significantly streamline the implementation of real-time authorization in chat applications, particularly when integrated with WebSockets. Here’s an overview of how this combination enhances dynamic access control:
- Seamless Integration: Permit.io offers a robust framework for managing fine-grained access controls that can be easily integrated into chat applications utilizing WebSockets. This integration allows for real-time permission checks and updates, ensuring that users have immediate access to the appropriate chat rooms and functionalities based on their roles and attributes.
- Dynamic Permission Management: With Permit.io, developers can implement dynamic authorization models that adapt to changes in user roles or group memberships. For instance, if a user is promoted to an admin role or temporarily granted special access, these changes can be reflected instantly across all active sessions without interrupting ongoing conversations. This capability addresses one of the primary challenges in dynamic authorization by ensuring that permissions are consistently enforced in real-time.
- Enhanced Performance: By leveraging WebSockets for communication, Permit.io ensures that real-time authorization processes do not compromise application performance. The architecture supports high volumes of messages and users, allowing for efficient handling of concurrent access requests while maintaining responsiveness—a critical requirement for chat applications.
- Role-Based and Attribute-Based Access Control: Permit.io facilitates the enforcement of both role-based and attribute-based access controls within chat environments. This flexibility allows administrators to define specific permissions for different user types, such as moderators or regular users, enhancing security while providing a customizable user experience. Users can participate in various chat types—public, private, or group based on their assigned roles.
- Regulatory Compliance: Implementing Permit.io’s solutions helps organizations meet stringent data privacy regulations by ensuring only authorized users can access sensitive information and functionalities within the chat application. This compliance is crucial for safeguarding user data and minimizing legal risks associated with unauthorized access.
Setting Up a WebSocket-Based Chat Application
For our web socket-based application, we’ll be using Next.js and Ably, a service that allows us to easily integrate and manage real-time capabilities in our apps powered by web socket.
In addition to Ably and Next Auth, we can use something like Firebase to handle both authentication and realtime features. There’s an entire tutorial about that on the Permit.io blog.
Without further ado, let’s proceed!
Setting up Next.js
Run the following command and follow the prompts:
npx create-next-app@latest live-chat
Need to install the following packages:
create-next-app@15.1.0
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
Creating a new Next.js app in /Users/miracleio/Documents/writing/permit/real-time-authorization-in-a-chat-application-with-permitio-and-websockets/live-chat.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc
added 371 packages, and audited 372 packages in 1m
141 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created live-chat at /Users/miracleio/Documents/writing/permit/real-time-authorization-in-a-chat-application-with-permitio-and-websockets/live-chat
Navigate to the newly created project folder and install a few more packages we’ll use to build our app:
cd live-chat
npm install -D prettier prettier-plugin-tailwindcss @tailwindcss/forms
Also, install a few UI compoents fron Radix UI:
npm install @radix-ui/react-scroll-area
Create a new file in the root of the project directory - .prettierrc
and enter the following:
{
"plugins": ["prettier-plugin-tailwindcss"]
}
In the tailwind.config.ts
file, enter the following:
// ./tailwind.config.ts
import type { Config } from "tailwindcss";
import tailwindForms from "@tailwindcss/forms";
export default {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [tailwindForms],
} satisfies Config;
In the `./next.config.ts`, enter the following:
// ./next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "www.tapback.co",
},
],
},
};
export default nextConfig;
Set up global styles
In the ./app/globals.css
file, enter the following:
/* ./app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-800 dark:bg-gray-900 dark:text-gray-300;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-full bg-gray-100 px-4 py-2 text-sm font-semibold text-gray-500 hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2 dark:bg-gray-800 dark:text-gray-300 dark:brightness-105 dark:hover:bg-gray-700 dark:focus:ring-gray-800 dark:focus:ring-offset-gray-900;
}
.btn:has(> .icon:first-child) {
@apply pl-2;
}
.btn:has(> .icon:last-child) {
@apply pr-2;
}
.icon {
@apply h-5 w-5 text-current;
}
.form-input {
@apply flex grow rounded-full border border-none bg-gray-100 px-4 py-2 text-sm font-semibold text-gray-500 outline-none hover:brightness-95 focus:border-none focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2 dark:bg-gray-800 dark:text-gray-300 dark:brightness-105 dark:hover:bg-gray-700 dark:focus:ring-gray-800 dark:focus:ring-offset-gray-900;
}
.site-section {
@apply py-16 md:py-24;
}
.site-section > .wrapper {
@apply mx-auto max-w-5xl px-4 sm:px-6 lg:px-8;
}
.noscroll {
@apply overflow-auto;
scrollbar-width: none;
}
}
Setting up Authentication
We’ll be using Auth.js, an authentication library originally built for Next.js.
Run the following command to install the package:
npm install next-auth@beta
We have to create an AUTH_SECRET
environment variable. The library uses this random value to encrypt tokens and email verification hashes. (See Deployment to learn more). You can generate one via the official Auth.js CLI running:
npx auth secret
📝 Created /Users/miracleio/Documents/writing/permit/real-time-authorization-in-a-chat-application-with-permitio-and-websockets/live-chat/.env.local with `AUTH_SECRET`.
Next, create the Auth.js config file and object - ./auth.js
:
// ./auth.ts
import NextAuth from "next-auth";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
});
Add a Route Handler under ./app/api/auth/[...nextauth]/route.ts
:
// ./app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"; // Referring to the auth.ts we just created
export const { GET, POST } = handlers;
Add optional Middleware to keep the session alive; this will update the session expiry every time it is called - ./middleware.ts
:
export { auth as middleware } from "@/auth"
Setting up Google OAuth
NextAuth supports multiple OAuth providers for authentication. For this tutorial, we’ll be using Google.
To obtain our Google client ID and secret, we have to set up a new project in Google Cloud Console - https://console.cloud.google.com/projectcreate
Next, in the newly created project, we’ll set up a new consent screen:
Once our consent screen has been created, we can set up credentials. Navigate to Credentials from the sidebar.
Click on the + Create Credentials button and select OAuth client ID from the dropdown.
In the following screen, select Web Application, enter the Authorized JavaScript origins, and redirect URIs: http://localhost:3000
and http://localhost:3000/api/auth/callback/google
respectively.
With that we should have our client ID and secret:
Copy the values and enter them into the .env
file:
# .env
AUTH_SECRET=secrettop # Added by `npx auth`. Read more: https://cli.authjs.dev
AUTH_GOOGLE_ID=livechat.apps.googleusercontent.com
AUTH_GOOGLE_SECRET=GOCSPX-livechat
Next, enable Google as a sign-in option in our Auth.js configuration. We’ll have to import the Google
provider from the package and pass it to the providers
array we set earlier in the Auth.js config file:
// ./auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Google],
});
Create a Site Header component
Let’s create a header for our app containing the sign-in and sign-out buttons. Create a new file - ./components/Site/Header.tsx
:
// ./components/Site/Header.tsx
// Importing necessary modules and functions:
// - `auth`, `signIn`, `signOut`: Authentication-related functions from the custom `auth` module.
// - `Image` from `next/image`: For optimized image rendering.
// - `Link` from `next/link`: For client-side navigation in Next.js.
import { auth, signIn, signOut } from "@/auth";
import Image from "next/image";
import Link from "next/link";
// Define the `SiteHeader` component as an async function since it fetches session data.
const SiteHeader = async () => {
// Fetch the current user's session using the `auth` function.
const session = await auth();
return (
// Define the header with styling for sticky positioning, background, and padding.
<header className="noscroll sticky top-0 z-10 overflow-auto bg-white px-4 py-4 sm:px-6 lg:px-8">
<div className="wrapper mx-auto flex max-w-5xl items-center justify-between gap-4">
{/* Navigation link to the homepage. */}
<Link href="/">
<figure className="flex items-center">
<span className="truncate text-xl font-bold tracking-tight text-gray-900 dark:text-white">
Live Chat
</span>
</figure>
</Link>
{/* Navigation menu */}
<nav className="site-nav flex">
<ul className="flex items-center gap-2">
{/* If the user is signed in, show their profile picture, name, and a sign-out button. */}
{session?.user ? (
<>
<li>
<button className="btn">
{/* Display the user's avatar using the `Image` component. */}
<Image
src={session?.user.image as string} // User's profile image URL.
alt="session?.user avatar" // Alternative text for accessibility.
width={32}
height={32}
className="icon rounded-full"
/>
<span className="truncate">{session?.user.name}</span>{" "}
{/* User's name. */}
</button>
</li>
<li>
<button
onClick={async () => {
"use server"; // Mark this function for server execution.
await signOut(); // Sign the user out when clicked.
}}
className="btn truncate"
>
Sign out
</button>
</li>
</>
) : (
// If no user is signed in, show a sign-in button.
<li>
<form
action={async () => {
"use server"; // Mark this function for server execution.
await signIn(); // Sign the user in when submitted.
}}
>
<button type="submit" className="btn">
Sign in
</button>
</form>
</li>
)}
</ul>
</nav>
</div>
</header>
);
};
// Export the `SiteHeader` component as the default export of this module.
export default SiteHeader;
Here, a SiteHeader
component serves as the main navigation bar.
If a user session exists (session?. user
), display the user’s avatar, name, and a “Sign out” button.
Otherwise, display a “Sign in” button, and since we’re using Next.js server actions, we put it inside a form.
The signIn
and signOut
functions are wrapped in server action markers ("use server"
) for server-side execution in Next.js.
In our ./app/layout.tsx
file import the SiteHeader
component:
// ./app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import SiteHeader from "@/components/Site/Header";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Live Chat",
description: "Live Chat with Next.js, NextAuth, Ably and Permit.io",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<SiteHeader />
{children}
</body>
</html>
);
}
In the homepage at ./app/page.tsx
, enter the following:
// ./app/page.tsx
export default function Home() {
return (
<main className="site-main">
<section className="site-section">
<div className="wrapper">
<h1 className="text-center text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl md:text-6xl lg:text-7xl dark:text-white">
Welcome to Live Chat
</h1>
</div>
</section>
</main>
);
}
With that, we should have something like this:
Now that we’ve set up authentication, let’s proceed to add websockets capabilities to our app with Ably.
Set up WebSockets with Ably
To get started, sign-up to Ably then Create a new app with the following options:
- App name: Call your app something meaningful
- Select your preferred language(s): JavaScript
- What type of app are you building? Live Chat
In the next screen, copy your API Key:
Save it in the .env
file:
# .env
# ...
ABLY_SECRET_KEY=bOpZEQ.E3l_gw:_tf-HaDekVTN2_vyPXdyjs0PT5NzWpXwPrEFJqBSahA
In our app, install the Ably React SDK and jose
library for JWT:
npm install ably jose
Once jose
and ably
installs, create ./app/api/ably/route.ts
:
// ./app/api/ably/route.ts
// Import the `auth` function to authenticate the user.
// Import `SignJWT` from the `jose` library to create JSON Web Tokens (JWT).
import { auth } from "@/auth";
import { SignJWT } from "jose";
// Function to create an Ably-compatible token.
// Parameters:
// - `clientId`: A unique identifier for the client.
// - `apiKey`: The Ably API key, which contains the app ID and signing key.
// - `claim`: An object indicating the user's role (e.g., whether they are a moderator).
// - `capability`: A map of channel permissions for the user.
const createToken = (
clientId: string,
apiKey: string,
claim: { isMod: boolean },
capability: { [key: string]: string[] | undefined },
) => {
// Extract the app ID and signing key from the provided Ably API key.
const [appId, signingKey] = apiKey.split(":", 2);
// Create a text encoder to encode the signing key for the JWT.
const enc = new TextEncoder();
// Create the JWT with Ably-specific claims and headers.
const token = new SignJWT({
// Specify the user's capabilities for Ably channels.
"x-ably-capability": JSON.stringify(capability),
// Associate the token with the client's unique identifier.
"x-ably-clientId": clientId,
// Include custom claims (e.g., moderation status).
"ably.channel.*": JSON.stringify(claim),
})
// Set the protected header with the app ID and algorithm used for signing.
.setProtectedHeader({ kid: appId, alg: "HS256" })
// Set the issued-at timestamp to the current time.
.setIssuedAt()
// Set the expiration time for the token to 24 hours.
.setExpirationTime("24h")
// Sign the token using the encoded signing key.
.sign(enc.encode(signingKey));
// Return the signed JWT.
return token;
};
// Function to generate channel permissions (capabilities) based on the user's role.
// Parameters:
// - `claim`: An object indicating the user's role (e.g., whether they are a moderator).
// Returns:
// - An object specifying the user's capabilities for different channels.
const generateCapability = (claim: { isMod: boolean }) => {
if (claim.isMod) {
// Moderators have full access to all channels.
return { "*": ["*"] };
} else {
// Regular users have specific permissions for certain channels.
return {
"chat:general": ["subscribe", "publish", "presence", "history"],
"chat:random": ["subscribe", "publish", "presence", "history"],
"chat:announcements": ["subscribe", "presence", "history"],
};
}
};
// Handler for the GET request to this API route.
// Generates an Ably token for the authenticated user.
export const GET = async () => {
// Authenticate the user and get their session.
const session = await auth();
const user = session?.user;
// Define the user's role as non-moderator by default.
const userClaim = {
isMod: false,
};
// Generate the user's channel permissions based on their role.
const userCapability = generateCapability(userClaim);
// If the user is authenticated, create a signed token for them.
const token =
user?.email &&
(await createToken(
user?.email, // Use the user's email as the client ID.
process.env.ABLY_SECRET_KEY as string, // Use the Ably secret key from environment variables.
userClaim, // Pass the user's role.
userCapability, // Pass the generated channel permissions.
));
// Return the generated token or an empty response if the token couldn't be created.
return Response.json(token || "");
};
Let’s break down what’s happening here:
-
JWT Token Creation: The createToken function generates a JSON Web Token compatible with Ably, including user capabilities and a client identifier. Here, we encode the
claim
in the token, meaning that theuserClaim
will be included in any events published by this client in topics with a name matching*
. - Dynamic Capabilities: The generateCapability function assigns permissions to users based on their role (moderator or regular user) for specific channels.
- User Authentication: The auth function is used to retrieve the user’s session, ensuring only authenticated users can request tokens.
- Environment Variables: The ABLY_SECRET_KEY environment variable securely stores the Ably API secret for token signing.
- API Response: The handler processes the request, generates the token, and returns it as a JSON response or an empty string if the user is unauthenticated.
Next, lt’s build out all the components we need for that chat page. We’ll start with the Message Item component that displays a single message sent by a user.
Message Item component
Create a new file - ./components/Message/Item.tsx
:
// ./components/Message/Item.tsx
// Import required types and components.
// `InboundMessage` is a type from Ably that represents a message received from a channel.
// `Image` is imported from Next.js for optimized image handling.
import { InboundMessage } from "ably";
import Image from "next/image";
// Define the `MessageItem` component as a functional React component with props.
// Props:
// - `message`: An object representing the message, including data and metadata.
// - `onDelete`: A callback function to handle message deletion, identified by a timeserial.
// - `fromUser`: A boolean indicating whether the message is sent by the current user.
const MessageItem: React.FC<{
message: InboundMessage;
onDelete: (timeserial: string) => void;
fromUser: boolean;
}> = ({ message, onDelete, fromUser }) => {
// Log the sender of the message and whether it's from the current user (for debugging).
console.log({ fromUser, user: message.clientId });
return (
// The outermost container adjusts its layout based on the sender.
// If the message is from the current user, it uses `flex-row-reverse` for alignment.
<div
className={`group relative flex w-fit items-start gap-2 rounded-3xl bg-gray-50 p-2 dark:bg-gray-800 ${
fromUser ? "flex-row-reverse" : "!flex-row"
}`}
>
{/* Display the avatar for the sender. */}
<figure className="shrink-0 p-1">
<Image
src={message.data.avatarUrl} // URL for the avatar image.
alt="avatar" // Alternative text for accessibility.
className="h-8 w-8 rounded-full" // Styling for a circular avatar.
width={32}
height={32}
/>
</figure>
{/* Display the message content. */}
<div
className={`flex flex-col ${
fromUser ? "pl-2 text-right" : "pr-2 text-left"
}`}
>
{/* The message text, styled for readability. */}
<span className={`font-medium text-gray-900 dark:text-white`}>
{message.data.text}
</span>
{/* The message timestamp, formatted as a localized time string. */}
<span
className={`truncate text-[0.625rem] text-gray-500 dark:text-gray-400`}
>
{new Date(message.timestamp).toLocaleTimeString()}
</span>
</div>
{/* If the message is from the current user, show a delete button. */}
{fromUser && (
<button
onClick={() => onDelete(message.id as string)} // Call the `onDelete` function with the message ID.
className="btn invisible absolute z-[5] flex group-hover:visible"
>
{/* Icon for the delete button, rendered as an SVG. */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="icon"
>
<path
fillRule="evenodd"
d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z"
clipRule="evenodd"
/>
</svg>
</button>
)}
</div>
);
};
// Export the `MessageItem` component for use in other parts of the application.
export default MessageItem;
This MessageItem
component dynamically adjusts its layout based on the fromUser
prop, using flex-row-reverse
for alignment.
It displays the sender’s avatar sourced from message.data.avatarUrl
. The message text (message.data.text)
and its timestamp (message.timestamp)
are shown, formatted as a localized time string.
For messages sent by the current user, a delete button, rendered with an SVG icon, is conditionally displayed and triggers the onDelete
callback with message.id
.
Message List component
Create a new file - ./components/Message/List.tsx
:
// ./components/Message/List.tsx
// Import required types and components.
// `InboundMessage` is a type from Ably that represents a message object.
// `MessageItem` is a child component that renders individual messages.
// `useSession` is a hook from NextAuth used to access the authenticated user's session data.
import { InboundMessage } from "ably";
import MessageItem from "@/components/Message/Item";
import { useSession } from "next-auth/react";
// Define the `MessageList` component as a functional React component with props.
// Props:
// - `messages`: An array of `InboundMessage` objects representing chat messages.
// - `onDelete`: A callback function to handle message deletion, identified by a timeserial.
const MessageList: React.FC<{
messages: InboundMessage[];
onDelete: (timeserial: string) => void;
}> = ({ messages, onDelete }) => {
// Retrieve the current session data to identify the logged-in user.
const session = useSession();
return (
// Render a list of messages as a vertical column.
// The container ensures messages are aligned at the bottom of the view and spaced apart.
<ul className="flex h-full flex-col justify-end gap-2">
{/* Map over the `messages` array to render each message. */}
{messages.map((message) => {
// Determine if the message is from the logged-in user by comparing their email
// with the message's `clientId` in a case-insensitive manner.
const fromUser =
session?.data?.user?.email?.trim().toLowerCase() ===
message.clientId?.trim().toLowerCase();
return (
// Each message is wrapped in a `li` element for semantic HTML.
// If the message is from the user, apply additional styles (`self-end`) for alignment.
<li
className={`flex flex-row items-start gap-2 ${fromUser ? "self-end" : ""}`}
key={message.id} // Use the message ID as the unique key for React's reconciliation.
>
{/* Render the `MessageItem` component for each message.
Pass the `message`, the `onDelete` callback, and the `fromUser` flag as props. */}
<MessageItem
message={message}
onDelete={onDelete}
fromUser={fromUser}
/>
</li>
);
})}
</ul>
);
};
// Export the `MessageList` component for use in other parts of the application.
export default MessageList;
The MessageList
component renders a scrollable list of messages using a messages.map()
loop, where each message is displayed through the MessageItem component.
It identifies if a message is sent by the logged-in user by comparing the session email (session.data.user.email)
with the message’s clientId
, styling user messages with self-end
for right alignment.
Each MessageItem receives its message
and the onDelete
callback.
Message Input component
Create new file - ./components/Message/Input.tsx
:
// ./components/Message/Input.tsx
// Import the `useState` hook to manage the input field's state.
import { useState } from "react";
// Define the `MessageInput` component as a functional React component with props.
// Props:
// - `onSubmit`: A callback function triggered when the form is submitted.
// - `disabled`: An optional boolean to enable/disable the input field and submission button.
const MessageInput: React.FC<{
onSubmit: (message: string) => void;
disabled?: boolean;
}> = ({ onSubmit, disabled = false }) => {
// State to store the current value of the input field.
const [input, setInput] = useState("");
// Event handler to update the `input` state as the user types.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
// Event handler to handle form submission.
// Prevents the default browser behavior, invokes the `onSubmit` callback with the input value,
// and clears the input field.
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit(input);
setInput("");
};
return (
// Form element for submitting a new message.
// Calls `handleSubmit` on form submission.
<form
onSubmit={handleSubmit}
className="flex items-center justify-between gap-4"
>
{/* Input field for entering the message. */}
<input
type="text" // Input type is text.
value={input} // Bind the input field's value to the `input` state.
onChange={handleChange} // Update the `input` state on change.
disabled={disabled} // Disable the field if `disabled` is true.
placeholder={
disabled ? "This input has been disabled." : "Your message here"
} // Show a contextual placeholder based on the `disabled` prop.
className="form-input" // Add styling to the input field.
/>
{/* Submit button to send the message. */}
<button className="btn primary" disabled={disabled}>
{/* SVG icon for an upward arrow, symbolizing submission. */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="icon"
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
/>
</svg>
</button>
</form>
);
};
// Export the `MessageInput` component for use in other parts of the application.
export default MessageInput;
The MessageInput
component manages user input with useState
and submits messages via onSubmit
, resetting the field after submission. The input field (<input>)
shows contextual placeholders and is disabled when the disabled prop is true.
Chat Channel List component
Create a new file - ./components/Chat/ChannelList.tsx
:
// ./components/Chat/ChannelList.tsx
// Imports necessary modules for navigation and routing
import Link from "next/link";
import { usePathname } from "next/navigation";
// Defines the available chat channels with their IDs and display names
export const channels = [
{ id: "general", name: "General" },
{ id: "random", name: "Random" },
{ id: "mod", name: "Moderators" },
];
const ChatChannelList = () => {
// Retrieves the current path to determine the active channel
const pathname = usePathname();
return (
<ul className="flex flex-col">
{channels.map((channel) => (
<li key={channel.id}>
{/* Creates a link for each channel */}
<Link
href={`/chat/${channel.id}`}
className={`${
pathname === `/chat/${channel.id}` ? "font-bold" : "" // Highlights the active channel
}`}
>
<span className="truncate">{channel.name}</span>{" "}
{/* Displays the channel name */}
</Link>
</li>
))}
</ul>
);
};
export default ChatChannelList; // Exports the component for use in other parts of the application
The ChatChannelList
component renders a list of channels as links. The active channel is highlighted using the font-bold
class based on the current pathname.
Chat component
Create a new file - ./components/Chat/index.tsx
:
// ./components/Chat/index.tsx
import { useEffect, useState } from "react";
import { useChannel } from "ably/react";
import { InboundMessage } from "ably";
import { useSession } from "next-auth/react";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import MessageList from "@/components/Message/List";
import MessageInput from "@/components/Message/Input";
// The Chat component handles a chat channel, listens to new messages,
// publishes new messages, and displays them in a scrollable area.
const Chat: React.FC<{ channelName: string }> = ({ channelName }) => {
const session = useSession(); // Access session data for user info
const [messages, setMessages] = useState<InboundMessage[]>([]); // Local state to store messages
// useChannel hook from Ably listens for messages on the provided channel
// and updates the messages list based on the received action.
const { channel, publish } = useChannel(channelName, (message) => {
if (message.name === "ADD" || message.name === "PROMOTE")
setMessages((messages) => [...messages, message as InboundMessage]);
if (message.name === "DELETE")
setMessages((messages) =>
messages.filter((m) => {
// Remove the message if it matches the one being deleted.
const matchingMessage = m.id === message.extras?.ref?.id;
const isOwnMessage = m.clientId === message.clientId;
return !(matchingMessage && isOwnMessage);
}),
);
if (message.name === "PROMOTE" || message.name === "DEMOTE") {
setMessages((messages) => [...messages, message as InboundMessage]);
}
});
// Function to publish a new message to the channel via Ably
const publishMessage = (text: string) => {
publish({
name: "ADD",
data: {
text, // Message content
avatarUrl: session?.data?.user?.image as string, // User avatar URL
},
});
};
// Function to handle deleting a message by publishing a delete event
const handleDelete = (id: string) => {
publish({
name: "DELETE",
extras: {
ref: { id },
},
});
};
// Fetching message history from the channel when the component mounts
useEffect(() => {
let ignore = false; // To prevent state updates after unmount
const fetchHist = async () => {
console.log("fetching history");
const history = await channel.history({
limit: 100, // Limit to the last 100 messages
direction: "forwards", // Fetch from earliest to latest
});
if (!ignore)
history.items.forEach((item) => {
if (item.name === "ADD")
setMessages((messages) => [...messages, item as InboundMessage]);
if (item.name === "DELETE")
setMessages((messages) =>
messages.filter((m) => {
const matchingMessage =
m.extras?.timeserial === item.extras?.ref?.timeserial;
const isOwnMessage = m.clientId === item.clientId;
return !(matchingMessage && isOwnMessage);
}),
);
});
};
fetchHist();
return () => {
ignore = true; // Cleanup on unmount to avoid memory leaks
};
}, [channel]); // Effect runs when the channel changes
return (
<>
{/* Scrollable area for message list */}
<ScrollArea.Root className="h-full w-full overflow-hidden rounded bg-white dark:bg-gray-900">
<ScrollArea.Viewport className="flex size-full flex-col justify-end rounded p-4">
{/* List of messages */}
<MessageList messages={messages} onDelete={handleDelete} />
</ScrollArea.Viewport>
{/* Vertical scrollbar for the message area */}
<ScrollArea.Scrollbar
className="flex touch-none select-none p-0.5 transition-colors ease-out data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="vertical"
>
<ScrollArea.Thumb className="relative flex-1 rounded-[10px] bg-gray-200 before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2" />
</ScrollArea.Scrollbar>
{/* Horizontal scrollbar for the message area */}
<ScrollArea.Scrollbar
className="flex touch-none select-none p-0.5 transition-colors ease-out data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col"
orientation="horizontal"
>
<ScrollArea.Thumb className="bg-mauve10 relative flex-1 rounded-[10px] before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-[44px] before:min-w-[44px] before:-translate-x-1/2 before:-translate-y-1/2" />
</ScrollArea.Scrollbar>
{/* Scroll area corner */}
<ScrollArea.Corner className="" />
</ScrollArea.Root>
{/* Message input field at the bottom */}
<div className="mt-auto p-5">
<MessageInput onSubmit={publishMessage} />
</div>
</>
);
};
export default Chat;
In this component, we’re doing a few things:
-
Message Handling: The component listens for messages (ADD, DELETE, PROMOTE) via
useChannel
and updates the message list accordingly. -
Publishing Messages: Messages are published using the
publishMessage
function, sending the message text and user avatar. -
ScrollArea: Radix UI’s
ScrollArea
is used for smooth scrolling in both vertical and horizontal directions for the message history. - Message History Fetching: On mount, we fetche the last 100 messages from the channel’s history.
Next, we’ll put everything together in the chat page.
Create Chat Page
Create a new file - ./app/chat/[[...channel]]/page.tsx
:
// ./app/chat/[[...channel]]/page.tsx
"use client"; // This ensures the component is rendered client-side, required for Ably and NextAuth.
// Import necessary components and providers
import Chat from "@/components/Chat"; // Chat component to handle chat messages and functionality.
import ChatChannelList from "@/components/Chat/ChannelList"; // Sidebar with a list of available chat channels.
import { Realtime } from "ably"; // Ably Realtime client for managing connections and messaging.
import { AblyProvider, ChannelProvider } from "ably/react"; // Providers for using Ably hooks in React components.
import { SessionProvider } from "next-auth/react"; // Provider for managing authentication state.
import { use } from "react"; // Hook to handle async data like the `params` object.
// The Page component defines the layout and functionality for the chat application.
const Page = ({ params }: { params: Promise<{ channel: string[] }> }) => {
// 👉 Create an Ably Realtime client instance
// - `authUrl`: URL for authorizing the connection using a token-based authentication strategy.
// - `autoConnect`: Ensures the client connects only when the app runs in the browser.
const client = new Realtime({
authUrl: "/api/ably", // Authorization endpoint to secure the Ably connection.
autoConnect: typeof window !== "undefined", // Only auto-connect in the browser.
});
// Extract the `channel` parameter from the dynamic route.
// The `params` object is an async promise containing the route parameters.
const { channel } = use(params);
// Format the channel name to match the expected format in the application.
// For example, if `channel` is "general", the `channelName` becomes "chat:general".
const channelName = `chat:${channel}`;
return (
// 👉 Wrap the entire application in providers to manage state and context.
// `SessionProvider`: Manages the user's authentication session globally.
<SessionProvider>
{/* `AblyProvider`: Provides Ably context to child components for accessing the Realtime client. */}
<AblyProvider client={client}>
{/* `ChannelProvider`: Supplies the current channel context to components like Chat. */}
<ChannelProvider channelName={channelName}>
{/* Main chat application layout */}
<section className="site-section !p-0">
<div className="wrapper grid h-[calc(100vh-4.25rem)] grid-cols-4 !p-0">
{/* Sidebar: Displays the list of available chat channels */}
<div className="h-full max-h-full rounded-xl bg-gray-50 p-5 dark:bg-gray-800">
<ChatChannelList />
</div>
{/* Main Chat Area: Center column for chat messages and input */}
<div className="col-span-2 flex h-full flex-col overflow-hidden">
{/* Pass the current channel name to the Chat component */}
<Chat channelName={channelName} />
</div>
{/* Right Sidebar: Reserved for additional features like a user list */}
<div className="col-span-1 flex h-full flex-col">
{/* TODO: Implement a user list or additional features in the right sidebar */}
</div>
</div>
</section>
</ChannelProvider>
</AblyProvider>
</SessionProvider>
);
};
export default Page; // Export the Page component as the default export.
The Page component wraps the chat app in providers for state and context management. The SessionProvider
ensures global access to the user’s session, while the AblyProvider
and ChannelProvider
enable seamless integration with Ably by sharing a Realtime client (authUrl: "/api/ably"
) and the current channelName (e.g., chat:general
).
The layout uses a grid with three sections: a left sidebar (<ChatChannelList />
for channel navigation), a center area (<Chat channelName={channelName} />
for messages), and a placeholder right sidebar (TODO: Users list) for future features like online users. The autoConnect option ensures Ably connects only in the browser, avoiding SSR issues.
With that, we should have something like this, when we navigate to http://localhost:3000/chat/general
:
Now that our cha
Set up Permit.io
Create a new account at https://www.permit.io/:
Create a new project
Enter the name of your project, I’ll be using Live Chat for this example:
Create new resource
To create channels for our chat app, Permit allows us to create resources which are entities that represent what users can have access to, let’s set up our channel as a resource to proceed:
Edit resuorce
Now we can edit the resource and add roles on our channel resource:
View roles
Here are the roles we’ve created. You can view it by going to the Roles tab on the Policy page.
Udpate policies
Now we can update our policies to determine who has access to what on each resource:
Create resource instance
Create resource instances for each channel we want in our chat, here we are creating an instance for the general channel, we can do same for random and mod.
View Instances
Here we can see the created resource instances:
Now that we’ve set up our Permit Dashboard, we can add Permit to our Next.js app.
Adding Permit.io to your Next.js application
Let’s dive in and start integrating Permit.io into our application.
First, we have to install the permitio
package:
npm install permitio
We have to obtain our API key from our Permit.io dashboard:
Add the copied key to the .env
file:
# .env
# ...
PERMIT_API_KEY=permit_key_0000
Set up local PDP
Next, we’ll have to set up our Policy Decision Point which is a network node responsible for answering authorization queries using policies and contextual data.
Pull the PDP container from Docker Hub (Click here to install Docker):
docker pull permitio/pdp-v2:latest
Run the container & replace the PDP_API_KEY
environment variable with your API key.
docker run -it \
-p 7766:7000 \
--env PDP_API_KEY=<YOUR_API_KEY> \
--env PDP_DEBUG=True \
permitio/pdp-v2:latest
Now that we have our PDP set up, let’s dive in to adding autoriaztion to our app, you can learn more about adding Permit.io to a Next.js app from this step-by-step tutorial on the Permit.io blog.
For this example, we’ll need to set up a few reusable functions and routes, lets start with creating a Permit library function in ./lib/permit.ts
:
// ./lib/permit.ts
import { Permit } from "permitio";
const PERMIT_API_KEY = process.env.PERMIT_API_KEY;
// This first line initializes the SDK and connects your Node.js app
// to the Permit.io PDP container you've set up in the previous step.
const permit = new Permit({
// your API Key
token: PERMIT_API_KEY,
// in production, you might need to change this url to fit your deployment
pdp: "http://localhost:7766",
// if you want the SDK to emit logs, uncomment this:
// log: {
// level: "debug",
// },
// The SDK returns false if you get a timeout / network error
// if you want it to throw an error instead, and let you handle this, uncomment this:
// throwOnError: true,
});
export default permit;
We’ll also create ./utils/permit.ts
file for all our permit-related utility functions:
// ./utils/permit.ts
import permit from "@/lib/permit";
/**
* Syncs a user with Permit.io and assigns roles.
*
* @param user - User details including id, first name, last name, and email.
* @param role - Role to be assigned to the user for a specific resource.
* @param resource_instance - Resource instance for which the role is assigned.
* @returns Object containing the results of default and specific role assignments.
*/
const handleSyncUser = async ({
user,
role,
resource_instance,
}: {
user: {
id: string;
first_name: string;
last_name: string;
email: string;
};
role: string;
resource_instance: string;
}) => {
try {
// Synchronize the user with Permit.io using their ID and basic information.
const syncUser = await permit.api.syncUser({
key: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
attributes: {}, // Add any custom attributes if needed.
});
// Assign a default role ("viewer") to the user for the default tenant.
const assignDefaultRole = await permit.api.assignRole({
role: "viewer",
tenant: "default",
user: syncUser.id,
});
// Assign a specific role to the user for a given resource instance.
const assignRole = await permit.api.assignRole({
role: role || "viewer", // Fallback to "viewer" if no role is provided.
tenant: "default",
user: syncUser.id,
resource_instance,
});
return { assignRole, assignDefaultRole }; // Return role assignment results.
} catch (error) {
console.error("Error syncing user with Permit.io: ", error); // Log error details.
return error; // Return error for further handling.
}
};
/**
* Retrieves a specific user's details from Permit.io using their ID.
*
* @param id - Unique identifier of the user.
* @returns User details retrieved from Permit.io.
*/
const handleGetPermitUser = async (id: string) => {
return await permit.api.getUser(id);
};
/**
* Retrieves a list of all users managed by Permit.io.
*
* @returns List of users.
*/
const handleGetPermitUsers = async () => {
return await permit.api.users.list();
};
/**
* Retrieves a list of all resource instances managed by Permit.io.
*
* @returns List of resource instances.
*/
const handleListResourceInstances = async () => {
return await permit.api.resourceInstances.list();
};
/**
* Retrieves the roles assigned to a user by their user ID.
*
* @param userId - Unique identifier of the user.
* @returns List of roles assigned to the user.
*/
const getUserRoles = async (userId: string) => {
try {
return await permit.api.getAssignedRoles(userId);
} catch (error) {
console.error("Error fetching user roles: ", error); // Log error details.
return []; // Return an empty array for graceful error handling.
}
};
export {
handleGetPermitUser,
handleSyncUser,
handleGetPermitUsers,
handleListResourceInstances,
getUserRoles,
};
User Hook
We’ll have to create a useUser
hook which will allow us to easily retrieve the permit user on the frontend. Create a new file ./hooks/useUser.ts
:
// ./hooks/useUser.ts
import { PermitUser } from "@/types/user";
import { User } from "next-auth";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useState } from "react";
/**
* Custom hook to fetch and manage user data from the Permit.io API.
*
* This hook combines Permit.io user data with NextAuth user data, returning a unified
* user object containing Permit.io attributes alongside NextAuth session properties.
*
* @returns An object containing the combined user data or `null` if not available.
*/
const useUser = () => {
// Extract the user data from the NextAuth session.
const sessionUser = useSession().data?.user;
// State to hold the combined user data.
const [user, setUser] = useState<(PermitUser & Partial<User>) | null>(null);
// Fetch user details from the Permit.io API and combine with NextAuth session data.
const getPermitUser = useCallback(async () => {
try {
// Make a request to the API endpoint to fetch Permit.io user data.
const res = await fetch("/api/permit/getUser");
// Parse the API response and cast it to the PermitUser type.
const user = (await res.json()).user as PermitUser;
// Merge Permit.io user data with session data (e.g., image and name from NextAuth).
setUser({
...user,
image: sessionUser?.image,
name: sessionUser?.name,
});
} catch (error) {
// Handle errors gracefully and reset the user state to `null`.
console.error("Error fetching Permit.io user data:", error);
setUser(null);
}
}, [sessionUser]);
useEffect(() => {
// Fetch user data only when sessionUser is available.
getPermitUser();
}, [sessionUser, getPermitUser]);
return { user, getPermitUser };
};
export default useUser;
Create Users in Permit During Authentication
To automatically add users to Permit during sign-in, use NextAuth’s signIn callback. During the callback:
- Fetch Resource Instances: Retrieve resource instances from Permit (e.g., a default workspace or project).
- Sync User Data: Use a utility function (like handleSyncUser) to create or update the user’s information in Permit. This includes their ID, email, name, role (e.g., “participant”), and the relevant resource instance.
- Complete Sign-In: The sign-in process will continue by returning true.
Replace the ./auth.ts
file with this updated code:
// ./auth.ts
// Import NextAuth for authentication and session management
import NextAuth from "next-auth";
// Import the Google provider for authentication via Google accounts
import Google from "next-auth/providers/google";
// Import utility functions for Permit.io integration
import { handleListResourceInstances, handleSyncUser } from "./utils/permit";
// Export handlers and authentication functions provided by NextAuth
export const { handlers, signIn, signOut, auth } = NextAuth({
// Define the list of authentication providers (Google in this case)
providers: [Google],
// Define custom callback functions for NextAuth
callbacks: {
// Custom sign-in callback function
async signIn(params) {
// Log the sign-in parameters for debugging
console.log("🟢🟢🟢🟢🟢 ~ params", params);
// Fetch the list of resource instances using the Permit.io utility
const resourceInstances = await handleListResourceInstances();
// Find the specific resource instance with the key "general"
const resourceInstance = resourceInstances.find(
(resourceInstance) => resourceInstance.key === "general",
);
// Log the fetched resource instance for debugging
console.log("🟢🟢🟢🟢🟢 ~ resourceInstance", resourceInstance);
// Sync the authenticated user's data with Permit.io, assigning them a role and associating them with the resource instance
handleSyncUser({
user: {
id: params.user.email as string, // User's email
email: params.user.email as string, // User's email
first_name: params.profile?.given_name as string, // User's first name
last_name: params.profile?.family_name as string, // User's last name
},
role: "participant", // Assign the role of "participant" to the user
resource_instance: resourceInstance?.id as string, // Associate the user with the "general" resource instance
});
// Allow the sign-in process to proceed
return true;
},
},
});
This ensures users are authenticated and seamlessly integrated into Permit for access control.
As we can see below, when the user signs in, their account is being added to our Permit dashboard:
We’ll have to set up a few more things before we can proceed, let’s starts with types to appease TypeScript as we develop our app. Create a new ./types/user.ts
file and enter the following:
// ./types/user.ts
// Importing necessary types from the "permitio" package.
// `RoleAssignmentRead` represents the structure of role assignment data,
// and `UserRead` represents the structure of user data.
import { RoleAssignmentRead, UserRead } from "permitio";
// Define a new type `PermitUser` by combining `UserRead` and `RoleAssignmentRead`.
// This represents a user with role assignment information in the context of Permit.io.
type PermitUser = UserRead & RoleAssignmentRead;
// Export the `PermitUser` type for use in other parts of the application.
// This allows consistent typing for users with roles and permissions.
export type { PermitUser };
Next, we’ll create a few API routes to obtain user data and permissions and promote and demote users.
Get Users Data
Create a new file - ./app/api/permit/getUsers/route.ts
:
// ./app/api/permit/getUsers/route.ts
import permit from "@/lib/permit"; // Importing the Permit.io instance from the specified library path.
import { getUserRoles } from "@/utils/permit";
// Exported GET handler function for fetching all users and their roles.
export const GET = async () => {
try {
// Fetch the list of users using the Permit.io API.
const res = await permit.api.users.list();
// Map over the users and fetch their roles asynchronously.
const users = await Promise.all(
res.data.map(async (user) => {
const roles = await getUserRoles(user.id); // Fetch roles for the current user.
return { ...user, roles }; // Merge user data with their roles.
}),
);
// Return a JSON response containing the list of users and their roles.
return Response.json({ users });
} catch (error) {
// Log any errors that occur during the process.
console.log("🔴🔴🔴🔴 ~ err: ", error);
// Return a JSON response with the error information for debugging.
return Response.json({ error });
}
};
Get User Data
Create a new file *./app/api/permit/getUser/route.ts*
:
// ./app/api/permit/getUser/route.ts
// Import the authentication function to verify and retrieve the authenticated user's information.
import { auth } from "@/auth";
// Import the Permit.io instance to interact with the Permit API.
import permit from "@/lib/permit";
// Import the helper function to fetch a user's assigned roles.
import { getUserRoles } from "@/utils/permit";
// Define the GET handler for the API route.
export const GET = async () => {
try {
// Retrieve the authenticated user's email from the `auth` function.
const userKey = (await auth())?.user?.email;
// If the user's email is not found, throw an error.
if (!userKey) throw new Error("User not found");
// Fetch the user details from the Permit.io API using their email as the unique key.
const res = await permit.api.users.get(userKey);
// Fetch the roles assigned to the user using the `getUserRoles` utility function.
const roles = await getUserRoles(res.id);
// Merge the user details with their roles into a single object.
const user = { ...res, roles };
// Log the user details for debugging purposes.
console.log("🚀 ~ user: ", user);
// Return the user data as a JSON response.
return Response.json({ user });
} catch (error) {
// Log any errors that occur during the process for debugging.
console.log("🔴🔴🔴🔴 ~ err: ", error);
// Return an error response in JSON format.
return Response.json({ error });
}
};
Promote User
Create a new file - ./app/api/permit/promoteUser/route.ts
and enter the following:
// ./app/api/permit/promoteUser/route.ts
import { auth } from "@/auth"; // Import the authentication module.
import permit from "@/lib/permit"; // Import the Permit.io instance for API interactions.
import { handleListResourceInstances } from "@/utils/permit"; // Utility function to fetch resource instances.
import { NextRequest } from "next/server"; // Import the Next.js request object type.
/**
* Promotes a user by assigning roles on specific resource instances.
* @param data - An object containing userKey, channel, role, and modChannel.
* @returns An object with the results of each role assignment operation.
*/
const promoteUser = async (data: {
userKey: string;
channel: string;
role: string;
modChannel: string;
}) => {
// Retrieve the current user's key (e.g., email or unique identifier).
const currentUserKey = (await auth())?.user?.email;
if (!currentUserKey) throw new Error("User not found");
// Check if the current user has permission to promote other users.
const canPromoteUser = await permit.check(
currentUserKey,
"promote",
"channel",
);
console.log("🟢 Can Promote User:", canPromoteUser);
if (!canPromoteUser) throw new Error("Cannot promote user");
// Assign the specified role to the user on the target channel.
const assignToModRoleOnChannel = await permit.api.assignRole({
role: data.role || "participant",
tenant: "default",
user: data.userKey,
resource_instance: data.channel,
});
// Assign the "participant" role to the user on the moderation channel.
const assignModChannelRole = await permit.api.assignRole({
role: "participant",
tenant: "default",
user: data.userKey,
resource_instance: data.modChannel,
});
// Assign the "admin" role to the user.
const assignAdminRole = await permit.api.assignRole({
role: "admin",
tenant: "default",
user: data.userKey,
});
// Return the results of each assignment operation.
return { assignModChannelRole, assignToModRoleOnChannel, assignAdminRole };
};
export const GET = async (request: NextRequest) => {
try {
// Extract query parameters from the request.
const searchParams = request.nextUrl.searchParams;
const key = searchParams.get("key"); // The user key (email or unique identifier).
const channel = searchParams.get("channel")?.split(":")[1]; // Extract the channel identifier.
// Validate that both the user key and channel are provided.
if (!key || !channel) throw new Error("User key or channel not provided");
// Fetch the list of resource instances from the Permit.io API.
const resourceInstances = await handleListResourceInstances();
// Find the specific resource instance for the given channel and the moderation channel.
const resourceInstance = resourceInstances.find(
(resource) => resource.key === channel,
);
const modChannel = resourceInstances.find(
(resource) => resource.key === "mod",
);
// Ensure the resource instances for the channel and moderation channel exist.
if (!resourceInstance?.id || !modChannel?.id) {
throw new Error("Resource not found");
}
// Execute the promotion process.
const res = await promoteUser({
channel: resourceInstance.id,
userKey: key,
role: "moderator",
modChannel: modChannel.id,
});
console.log("🟢 Promotion Result:", res);
// Return the result as a JSON response.
return new Response(JSON.stringify({ data: res }), {
status: 200,
});
} catch (error) {
// Log any errors that occur during the process.
console.error("🔴 Error:", error);
// Return the error message as a JSON response with a 400 status code.
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
};
Here we have a Next.js API route that promotes a user by assigning them specific roles on different resource instances using Permit.io.
The promoteUser
function checks if the current user has permission to promote others and assigns roles such as "participant," "moderator," and "admin" to the target user on specified channels.
The GET
function handles incoming requests, extracts query parameters, validates them, fetches resource instances, and executes the promotion process. It returns the results as a JSON response or an error message if any issues occur.
Demote User
Create a new file - ./app/api/permit/demoteUser/route.ts
and enter the following:
// ./app/api/permit/demoteUser/route.ts
import permit from "@/lib/permit"; // Import the Permit.io instance for API interactions.
import { handleListResourceInstances } from "@/utils/permit"; // Utility function to fetch resource instances.
import { NextRequest } from "next/server"; // Import the Next.js request object type.
/**
* Demotes a user by unassigning roles on specific resource instances.
* @param key - The user key (email or unique identifier).
* @param currentChannel - The channel to unassign the "moderator" role from.
* @returns An object with the status of each unassignment operation.
*/
const demoteUser = async (key: string, currentChannel: string) => {
// Unassign the "participant" role on the moderation channel.
const unassignModChannel = (
await permit.api.unassignRole({
role: "participant",
tenant: "default",
user: key,
resource_instance: "channel:mod",
})
).status;
// Unassign the "moderator" role on the current channel.
const unassignModRoleOnChannel = (
await permit.api.unassignRole({
role: "moderator",
tenant: "default",
user: key,
resource_instance: currentChannel,
})
).status;
// Unassign the "admin" role from the user.
const unassignAdminRole = (
await permit.api.unassignRole({
role: "admin",
tenant: "default",
user: key,
})
).status;
// Return the status of each unassignment operation.
return {
unassignModRoleOnChannel,
unassignModChannel,
unassignAdminRole,
};
};
export const GET = async (request: NextRequest) => {
try {
// Extract query parameters from the request.
const searchParams = request.nextUrl.searchParams;
const key = searchParams.get("key"); // The user key (email or unique identifier).
const channel = searchParams.get("channel")?.split(":")[1]; // Extract the channel identifier.
// Validate that both the user key and channel are provided.
if (!key || !channel) throw new Error("User key or channel not provided");
// Fetch the list of resource instances from the Permit.io API.
const resourceInstances = await handleListResourceInstances();
// Find the specific resource instance for the given channel and the moderation channel.
const resourceInstance = resourceInstances.find(
(resource) => resource.key === channel,
);
const modChannel = resourceInstances.find(
(resource) => resource.key === "mod",
);
// Ensure the resource instances for the channel and moderation channel exist.
if (!resourceInstance?.id || !modChannel?.id) {
throw new Error("Resource not found");
}
// Retrieve the roles currently assigned to the user.
const assignedRoles = await permit.api.getAssignedRoles(key);
console.log("🟢 Assigned Roles:", assignedRoles);
// Construct the current channel identifier in the required format.
const currentChannel = `channel:${channel}`;
console.log("🟢 Current Channel:", currentChannel);
// Ensure the current channel is valid before proceeding.
if (!currentChannel) throw new Error("Channel not found");
// Execute the demotion process.
const res = await demoteUser(key, currentChannel);
console.log("🟢 Demotion Result:", res);
// Return the result as a JSON response.
return new Response(JSON.stringify({ data: res }), {
status: 200,
});
} catch (error) {
// Log any errors that occur during the process.
console.error("🔴 Error:", error);
// Return the error message as a JSON response with a 400 status code.
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
};
Similarly, we have a Next.js API route that demotes a user by unassigning their roles on specific resource instances using Permit.io. The demoteUser
function removes roles such as "participant"
, "moderator"
, and "admin"
from the user on specified channels. The GET
function handles incoming requests, extracts query parameters, validates them, fetches resource instances, retrieves the user's assigned roles, and executes the demotion process.
Get Resources
Create a new file - ./app/api/permit/listResourceInstances/route.ts
and enter the following:
// ./app/api/permit/listResourceInstances/route.ts
import { handleListResourceInstances } from "@/utils/permit";
export const GET = async () => {
const resourceInstances = await handleListResourceInstances();
return Response.json(resourceInstances);
};
Update Ably permissions route
In our file ./app/api/ably/route.ts
, replace it with the following updated code:
// ./app/api/ably/route.ts
import { auth } from "@/auth"; // Authentication utility to validate user sessions.
import { SignJWT } from "jose"; // Library for creating JSON Web Tokens (JWT).
import { handleGetPermitUsers } from "@/utils/permit"; // Utility to fetch Permit.io users.
import permit from "@/lib/permit"; // Instance of Permit.io SDK.
import { RoleAssignmentRead } from "permitio"; // Type definition for Permit.io role assignments.
/**
* Creates an Ably-compatible JWT token.
* @param clientId - Unique identifier for the client (e.g., user's email).
* @param apiKey - Ably API key consisting of app ID and signing key.
* @param claim - User-specific claims (e.g., is the user a moderator?).
* @param capability - Mapping of channels to permissions for the user.
* @returns A signed JWT token.
*/
const createToken = (
clientId: string,
apiKey: string,
claim: { isMod: boolean },
capability: { [key: string]: string[] | undefined },
) => {
// Extract the Ably app ID and signing key from the API key.
const [appId, signingKey] = apiKey.split(":", 2);
// Use TextEncoder to encode the signing key.
const enc = new TextEncoder();
// Create and sign a JWT with Ably-specific claims.
return new SignJWT({
"x-ably-capability": JSON.stringify(capability), // Specify channel permissions.
"x-ably-clientId": clientId, // Associate the token with the client.
"ably.channel.*": JSON.stringify(claim), // Include custom claims (e.g., moderator status).
})
.setProtectedHeader({ kid: appId, alg: "HS256" }) // Include app ID and algorithm.
.setIssuedAt() // Set issued-at timestamp to now.
.setExpirationTime("24h") // Set expiration to 24 hours.
.sign(enc.encode(signingKey)); // Sign the token with the encoded key.
};
/**
* Generates a permissions object based on the user's roles.
* @param roles - Array of roles assigned to the user.
* @returns An object mapping channel names to allowed actions.
*/
const generatePermissions = (roles: RoleAssignmentRead[]) => {
// Define a mapping of roles to their respective permissions.
const rolePermissions: Record<string, string[]> = {
moderator: ["subscribe", "publish", "presence", "history"],
participant: ["subscribe", "publish", "presence"],
viewer: ["subscribe"],
};
// Transform the user's roles into a permissions object.
return roles.reduce(
(permissions, role) => {
const resourceInstance = role.resource_instance?.split(":")[1]; // Extract channel name.
const rolePerms = rolePermissions[role.role]; // Get permissions for the role.
if (resourceInstance && rolePerms) {
permissions[`chat:${resourceInstance}`] = rolePerms; // Map permissions to the channel.
}
return permissions;
},
{} as Record<string, string[]>,
);
};
/**
* API handler for generating an Ably token for the authenticated user.
* @returns A JSON response containing the Ably token or an empty string.
*/
export const GET = async () => {
// Authenticate the user and retrieve their session data.
const session = await auth();
const user = session?.user;
// Fetch all users from Permit.io and locate the current user by email.
const permitUsers = await handleGetPermitUsers();
const permitUser = permitUsers.data.find(
(permitUser) => permitUser.email === user?.email,
);
// Retrieve the roles assigned to the user from Permit.io.
const userAssignedRoles = await permit.api.getAssignedRoles(
permitUser?.id as string,
);
// Generate channel permissions based on the user's roles.
const permissions = generatePermissions(userAssignedRoles);
// Determine if the user has moderator privileges.
const userClaim = {
isMod: !!userAssignedRoles.find(
(role) => role.resource_instance === "channel:mod",
),
};
// Define the user's channel capabilities. Moderators have full access.
const userCapability = userClaim.isMod ? { "*": ["*"] } : permissions;
// Generate a signed JWT token for the authenticated user.
const token =
user?.email &&
(await createToken(
user.email, // Use the user's email as the client ID.
process.env.ABLY_SECRET_KEY as string, // Retrieve the Ably secret key from environment variables.
userClaim, // Pass the user's role claims.
userCapability, // Include the generated channel permissions.
));
// Return the token or an empty string if token generation fails.
return Response.json(token || "");
};
Here we incorporate Permit.io for managing roles and permissions, replacing static configurations with dynamic role-based permissions.
The generatePermissions
function uses data from Permit.io to map roles to specific channel capabilities, ensuring permissions are aligned with user roles in real-time. This approach improves flexibility and ensures the system adapts as roles or permissions change, integrating seamlessly with Ably’s JWT-based authentication.
Update Channel List
Now that we’ve added the resources (channels) to our permit dashboard, we can fetch them from there instead of hardcoding them.
In the ./components/Chat/ChannelList.tsx
file, make the following changes:
// ./components/Chat/ChannelList.tsx
// Imports necessary modules for navigation and routing
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
// Defines the available chat channels with their IDs and display names
export const getChannelList = async () => {
const resourceInstances = await fetch("/api/permit/resourceInstances").then(
(res) => res.json(),
);
const channels = resourceInstances.map(
(resourceInstance: { key: string }) => ({
id: resourceInstance.key,
}),
);
return channels;
};
const ChatChannelList = () => {
// Retrieves the current path to determine the active channel
const pathname = usePathname();
const [channels, setChannels] = useState<{ id: string }[]>([]);
useEffect(() => {
const fetchChannels = async () => {
const channels = await getChannelList();
setChannels(channels);
};
fetchChannels();
}, []);
return (
<ul className="flex flex-col">
{channels.map((channel) => (
<li key={channel.id}>
{/* Creates a link for each channel */}
<Link
href={`/chat/${channel.id}`}
className={`${
pathname === `/chat/${channel.id}` ? "font-bold" : "" // Highlights the active channel
}`}
>
<span className="truncate">
{channel.id == "mod" ? "🔒" : "💬"} {channel.id}
</span>{" "}
{/* Displays the channel name */}
</Link>
</li>
))}
</ul>
);
};
export default ChatChannelList; // Exports the component for use in other parts of the application
Create User List component
Let’s create a user list component that fetches all the permit users and display promote or demote buttons next to the user name.
Create a new file - ./components/Chat/UserList.tsx
and enter the following:
// ./components/Chat/UserList.tsx
import useUser from "@/hooks/useUser"; // Custom hook to fetch the current user's information.
import { useChannel } from "ably/react"; // Ably hook for real-time channel communication.
import { UserRead } from "permitio"; // Type definition for Permit.io user data.
import { useCallback, useEffect, useState } from "react";
/**
* Fetches the list of users from the server using the Permit.io API.
* @returns A JSON object containing the list of users.
*/
const getUserList = async () => {
const res = await fetch("/api/permit/getUsers");
return await res.json();
};
/**
* Component to display and manage a list of chat users with promote/demote actions.
* @param {Object} props - Component props.
* @param {string} props.channelName - The name of the chat channel.
*/
const ChatUserList: React.FC<{ channelName: string }> = ({ channelName }) => {
const { user: currentUser, getPermitUser } = useUser(); // Get the current logged-in user's details.
const [users, setUsers] = useState<UserRead[]>([]); // State to hold the list of users.
const [canPromote, setCanPromote] = useState(false); // State to determine if the current user can promote others.
// Subscribe to the Ably channel for real-time updates.
const { publish } = useChannel(channelName, (message) => {
if (message.name === "PROMOTE" || message.name === "DEMOTE") {
fetchUsers(); // Fetch the updated user list when a promote/demote event occurs.
getPermitUser(); // Fetch the updated Permit.io user data when a promote/demote event occurs.
}
});
/**
* Fetches and updates the list of users, excluding the current user.
*/
const fetchUsers = useCallback(async () => {
const data = await getUserList();
setUsers(
data.users.filter((user: UserRead) => user.id !== currentUser?.id),
);
}, [currentUser?.id]);
/**
* Handles promoting a user to a moderator role.
* @param {string} [userKey] - The unique identifier for the user to promote.
*/
const handlePromoteUser = async (userKey?: string) => {
try {
const res = await fetch(
`/api/permit/promoteUser?key=${userKey}&channel=${channelName}`,
);
if (!res.ok) throw new Error("Failed to promote user");
// Notify other clients about the promotion.
publish({
name: "PROMOTE",
data: {
id: userKey,
text: `User ${userKey} has been promoted to moderator`,
avatarUrl: `https://www.tapback.co/api/avatar/${userKey}`,
role: "moderator",
},
});
} catch (error) {
console.error("🔴 Error promoting user:", error);
}
};
/**
* Handles demoting a user from a moderator role.
* @param {string} [userKey] - The unique identifier for the user to demote.
*/
const handleDemoteUser = async (userKey?: string) => {
try {
const res = await fetch(
`/api/permit/demoteUser?key=${userKey}&channel=${channelName}`,
);
if (!res.ok) throw new Error("Failed to demote user");
// Notify other clients about the demotion.
publish({
name: "DEMOTE",
data: {
id: userKey,
text: `User ${userKey} has been demoted from moderator`,
avatarUrl: `https://www.tapback.co/api/avatar/${userKey}`,
role: "participant",
},
});
} catch (error) {
console.error("🔴 Error demoting user:", error);
}
};
// Fetch the list of users whenever the current user's ID changes.
useEffect(() => {
fetchUsers();
}, [currentUser?.id, fetchUsers]);
// Determine if the current user has permission to promote others.
useEffect(() => {
setCanPromote(
!!currentUser?.roles?.find((role) => role.role === "moderator"),
);
}, [currentUser]);
return (
<ul className="flex h-full flex-col gap-2 rounded-2xl bg-gray-50 p-6 dark:bg-gray-800">
{users.map((user) => (
<li key={user.id}>
<article className="text-sm">
<div className="flex items-center justify-between gap-2">
{/* Display user's name if available */}
{user.first_name && (
<p>
{user.first_name} {user.last_name}
</p>
)}
{canPromote &&
(!user?.roles?.find((role) => role.role === "moderator") ? (
<button
className="btn"
onClick={() => handlePromoteUser(user?.email)}
>
Promote
</button>
) : (
<button
className="btn"
onClick={() => handleDemoteUser(user?.email)}
>
Demote
</button>
))}
</div>
</article>
</li>
))}
</ul>
);
};
export default ChatUserList;
Here, we use a custom hook useUser
to fetch the current user's information and the useChannel
hook from Ably for real-time channel communication. The getUserList
function fetches the list of users from the server using the Permit.io API. The component subscribes to the Ably channel for real-time updates and fetches the updated user list when a promote/demote event occurs. The user list is stored in the component's state, excluding the current user.
Finally, we can add it to our page, in ./app/chat/[[...channel]]/page.tsx
:
// ./app/chat/[[...channel]]/page.tsx
"use client"; // This ensures the component is rendered client-side, required for Ably and NextAuth.
// Import necessary components and providers
import Chat from "@/components/Chat"; // Chat component to handle chat messages and functionality.
import ChatChannelList from "@/components/Chat/ChannelList"; // Sidebar with a list of available chat channels.
import ChatUserList from "@/components/Chat/UserList";
import { Realtime } from "ably"; // Ably Realtime client for managing connections and messaging.
import { AblyProvider, ChannelProvider } from "ably/react"; // Providers for using Ably hooks in React components.
import { SessionProvider } from "next-auth/react"; // Provider for managing authentication state.
import { use } from "react"; // Hook to handle async data like the `params` object.
// The Page component defines the layout and functionality for the chat application.
const Page = ({ params }: { params: Promise<{ channel: string[] }> }) => {
// 👉 Create an Ably Realtime client instance
// - `authUrl`: URL for authorizing the connection using a token-based authentication strategy.
// - `autoConnect`: Ensures the client connects only when the app runs in the browser.
const client = new Realtime({
authUrl: "/api/ably", // Authorization endpoint to secure the Ably connection.
autoConnect: typeof window !== "undefined", // Only auto-connect in the browser.
});
// Extract the `channel` parameter from the dynamic route.
// The `params` object is an async promise containing the route parameters.
const { channel } = use(params);
// Format the channel name to match the expected format in the application.
// For example, if `channel` is "general", the `channelName` becomes "chat:general".
const channelName = `chat:${channel}`;
return (
// 👉 Wrap the entire application in providers to manage state and context.
// `SessionProvider`: Manages the user's authentication session globally.
<SessionProvider>
{/* `AblyProvider`: Provides Ably context to child components for accessing the Realtime client. */}
<AblyProvider client={client}>
{/* `ChannelProvider`: Supplies the current channel context to components like Chat. */}
<ChannelProvider channelName={channelName}>
{/* Main chat application layout */}
<section className="site-section !p-0">
<div className="wrapper grid h-[calc(100vh-4.25rem)] grid-cols-4 !p-0">
{/* Sidebar: Displays the list of available chat channels */}
<div className="h-full max-h-full rounded-xl bg-gray-50 p-5 dark:bg-gray-800">
<ChatChannelList />
</div>
{/* Main Chat Area: Center column for chat messages and input */}
<div className="col-span-2 flex h-full flex-col overflow-hidden">
{/* Pass the current channel name to the Chat component */}
<Chat channelName={channelName} />
</div>
{/* Right Sidebar: Reserved for additional features like a user list */}
<div className="col-span-1 flex h-full flex-col">
{/* TODO: Implement a user list or additional features in the right sidebar */}
<ChatUserList channelName={channelName} />
</div>
</div>
</section>
</ChannelProvider>
</AblyProvider>
</SessionProvider>
);
};
export default Page; // Export the Page component as the default export.
With that we should be able to promote and demote users in real time:
Here’s the demotion in action:
Closing and Conclusion
Building a chat application with real-time authorization is a challenging but rewarding process. By integrating powerful tools like Permit.io and WebSockets, you can create a seamless experience that ensures secure and fine-grained access control. In this article, we explored the importance of dynamic authorization in chat applications, set up a WebSocket-based architecture with Ably, and integrated Permit.io for authorization management.
This workflow demonstrates how modern tools simplify what was once a complex implementation, enabling developers to focus more on user experience and scalability rather than the underlying infrastructure. With the right approach, you can ensure your chat application is both dynamic and secure.
Further Reading and Resources
- GitHub Code - https://github.com/miracleonyenma/live-chat
- Permit.io Documentation – A comprehensive guide to Permit.io’s capabilities and APIs.
- Ably WebSockets Documentation – Learn more about building real-time apps with Ably.
- Next.js Documentation – Explore advanced features for building React applications with Next.js.
- Auth.js Documentation – Set up secure and scalable authentication in your Next.js apps.
- WebSockets for Real-Time Web Applications – An in-depth overview of WebSockets and their use cases.
Next Steps
With the foundational setup complete, you can explore simple and advanced features like:
- Allowing moderators to delete participants messages
- Adding AI-powered moderation tools to detect and prevent abusive content in chat. You can learn more about Building AI Applications with Permit
- Implementing analytics dashboards to track user activity and message trends.
Top comments (0)