DEV Community

Cover image for Part 1: Master Authentication and Role-Based Access Control (RBAC) with Kinde and Convex in a File-Sharing Application
Shola Jegede
Shola Jegede

Posted on

Part 1: Master Authentication and Role-Based Access Control (RBAC) with Kinde and Convex in a File-Sharing Application

Kinde is an authentication and identity management platform that helps developers implement secure user authentication, role-based access control (RBAC), and social logins (e.g., Google, Facebook, GitHub).

It offers passwordless sign-ins, SSO, and webhooks for real-time user data syncing, making it easy to manage user access and permissions in web applications.

Whereas Convex provides you with a fully featured backend with cloud functions, database, scheduling, and a sync engine that keeps your frontend and backend up to date in real-time.

What You'll Learn in this Tutorial

  • Setting up Kinde in your Next.js app: Learn how to integrate Kinde for user authentication.
  • Supporting multiple authentication providers: Integrate social logins like Google, Facebook and GitHub.
  • Creating protected routes with middleware: Restrict access to unauthenticated routes.
  • User Authentication Workflow: Manage login and logout seamlessly.
  • Configuring Convex for user data management: Set up Convex to store and manage user data.
  • Using webhooks to sync authentication events: Sync user authentication and data events from Kinde to Convex.
  • Introduction to RBAC and Setup: How to restrict access to functionalities based on roles, enhancing security.

Prerequisites

  • Node.js installed on your computer.
  • Knowledge of TypeScript React, and Next.js.
  • A free account on Kinde to manage authentication.
  • A free account on Convex for user data management.

Get started

Setting Up the Starter Repository

To simplify the process, we've created a starter repository that includes:

  • A pre-configured Next.js project with TypeScript and TailwindCSS.
  • Shadcn UI components already set up.
  • Basic app router folder structure.
  • Essential libraries pre-installed.

Clone the Starter Project

First, clone the starter repository:

git clone https://github.com/sholajegede/nextjs-shadcn-starter.git
cd nextjs-shadcn-starter
Enter fullscreen mode Exit fullscreen mode

Install the dependencies:

npm install
# or
yarn install
# or
bun install
Enter fullscreen mode Exit fullscreen mode

Start the development server:

npm run dev
# or
yarn dev
# or
bun run dev
Enter fullscreen mode Exit fullscreen mode

Navigate to http://localhost:3000 in your browser. You should see a simple interface that looks like this:

Image description

Steps to Get Started with Kinde in your Next.js Project

1. Visit Kinde's Website

  • Go to the Kinde website via the link in the tutorial.
  • If you don’t have an account, click "Start for free" to create one.
  • Log in to access the dashboard.

2. Create a New Project

  • In the Kinde dashboard, click on "Add Business" (projects are referred to as businesses in Kinde's terminology).
  • Fill in the project details (e.g., name, region, number of employees).
  • Save the project and access the onboarding flow.

3. Configure the Project

  • Select the option to integrate Kinde into an existing project.

Image description

  • Choose the appropriate SDK (Next.js in this case).

Image description

  • Select authentication methods "Google and Facebook".

Image description

4. Access the Documentation

  • Navigate to the documentation from the dashboard to find detailed setup instructions for your SDK.
  • For this tutorial, we would be using Next.js, see the Next App Router setup guide.
  • If you would like to use Next.js Pages Router, see the setup guide.

5. Install Kinde SDK

  • Use the provided installation command:
npm i @kinde-oss/kinde-auth-nextjs
Enter fullscreen mode Exit fullscreen mode
  • Restart your development server if necessary.

6. Integrate Kinde into the Codebase

  • Configure environment variables:
KINDE_CLIENT_ID=<your_kinde_client_id>
KINDE_CLIENT_SECRET=<your_kinde_client_secret>
KINDE_ISSUER_URL=https://<your_kinde_subdomain>.kinde.com
KINDE_SITE_URL=http://localhost:3000
KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000
KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard
Enter fullscreen mode Exit fullscreen mode
  • Replace the information in the example with your own information. (i) In Kinde, go to your Dashboard > Applications > [Your app > View details (ii) Scroll down to "app keys", there you would see your Domain, Client ID, and Client Secret

7. Set up Kinde Auth Route Handlers
Create the following file app/api/auth/[kindeAuth]/route.ts inside your Next.js project. Inside the file route.ts put this code:

import {handleAuth} from "@kinde-oss/kinde-auth-nextjs/server";
export const GET = handleAuth();
Enter fullscreen mode Exit fullscreen mode

This will handle Kinde Auth endpoints in your Next.js app.

Steps to Get Started with Convex in your Next.js Project

1. Install Convex
To get started, install the convex package which provides a convenient interface for working with Convex from a Next.js app:

npm install convex
Enter fullscreen mode Exit fullscreen mode

2. Set up a Convex dev deployment
Next, run npx convex dev. This will prompt you to log in with GitHub, create a project, and save your production and deployment URLs.

It will also create a convex/ folder for you to write your backend API functions in. The dev command will then continue running to sync your functions with your dev deployment in the cloud.

As part of this setup, Convex automatically sets some environment variables for your deployment, such as:

  • CONVEX_DEPLOYMENT: Specifies the deployment name (e.g., dev:necessary-jackal-122).
  • NEXT_PUBLIC_CONVEX_URL: The public-facing URL for your Convex backend (e.g., https://necessary-jackal-122.convex.cloud).
npx convex dev
Enter fullscreen mode Exit fullscreen mode

For webhooks, which we will cover later in the tutorial, you'll also need to add:

NEXT_PUBLIC_CONVEX_HTTP_URL: The HTTP endpoint for handling webhooks (e.g., https://necessary-jackal-122.convex.site).

3. Create a Schema for your project
The schema defines the structure of your database tables and the fields each table contains. To do this, go into your convex folder and create a schema.ts file. In it, copy and paste this code:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    email: v.string(),
    kindeId: v.string(),
    username: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
    notificationType: v.optional(v.string()),
    communication_updates: v.optional(v.boolean()), // true or false : default = true
    marketing_updates: v.optional(v.boolean()), // true or false
    social_updates: v.optional(v.boolean()), // true or false
    security_updates: v.optional(v.boolean()), // true or false : default = true
    stripeId: v.optional(v.string()),
  }),
});
Enter fullscreen mode Exit fullscreen mode
  • email and kindeId: Required fields storing user's email and unique identifier which would be gotten from Kinde after authentication.
  • username and imageUrl: Optional fields for storing the first name and profile image/picture from Kinde after authentication.
  • imageStorageId: Links to an uploaded image stored in Convex's storage system. This would be stored when a user updates their profile image in the application.
  • notificationType and update preferences (communication_updates, marketing_updates, etc.): Boolean fields that let users customize their notification preferences.
  • stripeId: Optionally stores the user's Stripe ID for payment integration.

4. Create the auth config
In the convex folder create a new file auth.config.ts with the server-side configuration for validating access tokens.

Paste in your KINDE_ISSUER_URL from your .env.local file and set applicationID to convex.

const authConfig = {
  providers: [
    {
      domain: process.env.KINDE_ISSUER_URL,
      applicationID: "convex",
    },
  ]
};

export default authConfig;
Enter fullscreen mode Exit fullscreen mode

5. Deploy your changes
Run npx convex dev to automatically sync your configuration to your backend.

npx convex dev
Enter fullscreen mode Exit fullscreen mode

6. Configure your ConvexKindeProvider
To integrate Convex and Kinde authentication in your app, you'll need a custom provider component. Follow these steps to create and configure it:

  • Create the providers folder: At the root of your project, create a folder named providers. This will hold custom provider components.
  • Create the ConvexKindeProvider.tsx file: Inside the providers folder, create a new file named ConvexKindeProvider.tsx.
  • Paste the code: Copy and paste the provided code into this file.
"use client";

import { ReactNode, useEffect } from "react";
import { KindeProvider, useKindeAuth } from "@kinde-oss/kinde-auth-nextjs";
import { ConvexProvider, ConvexReactClient, AuthTokenFetcher } from "convex/react";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL as string);

const ConvexKindeProvider = ({ children }: { children: ReactNode }) => {
  const { getToken } = useKindeAuth();

  useEffect(() => {
    const fetchToken: AuthTokenFetcher = async () => {
      const token = await getToken();
      return token || null;
    };

    if (typeof getToken === "function") {
      convex.setAuth(fetchToken);
    }
  }, [getToken]);

  return (
    <KindeProvider
      domain={process.env.NEXT_PUBLIC_KINDE_DOMAIN as string}
      clientId={process.env.NEXT_PUBLIC_KINDE_CLIENT_ID as string}
      redirectUri={process.env.NEXT_PUBLIC_KINDE_REDIRECT_URI as string}
    >
      <ConvexProvider client={convex}>{children}</ConvexProvider>
    </KindeProvider>
  );
};

export default ConvexKindeProvider;
Enter fullscreen mode Exit fullscreen mode

What the Code Does
The ConvexKindeProvider component integrates Kinde's authentication with Convex's data synchronization, ensuring authenticated user sessions are securely managed. Here's a breakdown of its functionality:

  • Import Required Libraries:
    • KindeProvider and useKindeAuth: Handle authentication using Kinde.
    • ConvexProvider and ConvexReactClient: Manage Convex's real-time database and syncing capabilities.
  • Create a Convex Client:
    • The ConvexReactClient is initialized with your Convex app's URL (NEXT_PUBLIC_CONVEX_URL).
  • Fetch and Set Auth Token:
    • A fetchToken function is defined using Kinde's getToken() to retrieve the user's authentication token.
    • The token is passed to Convex's setAuth method to secure API requests.
  • Use Effect Hook:
    • Ensures the token-fetching logic runs whenever getToken changes.
  • Render Children with Providers:
    • Wraps your app's components (children) in both the KindeProvider and ConvexProvider, enabling authentication and database access across your app.

Key Features of This Setup

  • Secure Authentication:
    • Retrieves and uses Kinde's authentication token to authenticate users with Convex.
  • Environment Variables:
    • Uses environment variables (NEXT_PUBLIC_KINDE_DOMAIN, NEXT_PUBLIC_KINDE_CLIENT_ID, etc.) to configure Kinde and Convex securely.
  • Reusable Component:
    • Encapsulates all authentication and data sync logic into a single component, simplifying integration across your app.

By including this provider in your app, you ensure seamless integration between Kinde's authentication and Convex's real-time data capabilities.

7. Pass your ConvexKindeProvider to your root layout.tsx
To ensure Kinde authentication and Convex database synchronization are accessible across your entire application, you need to wrap your root layout with the ConvexKindeProvider. This allows all child components to inherit these features without additional setup.

Here's how you can do it:

  • Open your layout.tsx file.
  • Wrap your content inside the ConvexKindeProvider as shown below:
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import ConvexKindeProvider from "@/providers/ConvexKindeProvider";
import { TooltipProvider } from "@/components/ui/tooltip";

const geistSans = localFont({
  src: "../fonts/GeistVF.woff",
  variable: "--font-geist-sans",
  weight: "100 900",
});
const geistMono = localFont({
  src: "../fonts/GeistMonoVF.woff",
  variable: "--font-geist-mono",
  weight: "100 900",
});

export const calSans = localFont({
  src: "../fonts/CalSans-SemiBold.ttf",
  variable: "--font-calsans",
})

export const metadata: Metadata = {
  title: "File Share App",
  description: "The fastest and most secure way to share your files.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ConvexKindeProvider>
      <html lang="en" suppressHydrationWarning>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            <TooltipProvider>
              {children}
            </TooltipProvider>
          </ThemeProvider>
        </body>
      </html>
    </ConvexKindeProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Why This Matters

  • Global Authentication: Wrapping the root layout ensures all components in your app can access authenticated sessions.
  • Real-Time Data: Convex's real-time updates are now available throughout your app.
  • Simplified Setup: Centralizing this logic at the root reduces repetitive code and ensures consistent behavior.

By following this step, your app is fully equipped to handle authentication and real-time data syncing seamlessly.

Middleware Setup to Protect Routes

To protect your app’s routes and handle authentication, you’ll use middleware to redirect unauthenticated users and manage their access.

Step 1: Create the Middleware File
In the root directory (outside the app directory), create a file named middleware.ts.

Step 2: Define the Middleware
Here’s the complete code to define and configure the middleware:

import { withAuth } from "@kinde-oss/kinde-auth-nextjs/middleware";

export default withAuth({
  loginPage: "/api/auth/login", // Redirect unauthenticated users to this page
  isReturnToCurrentPage: true, // Redirect users back to their current page after logging in
});

export const config = {
  matcher: [
    /*
     * Protect all routes except:
     * - api (API routes)
     * - about, privacypolicy, termsofservice (public pages)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico, sitemap.xml, robots.txt (metadata files)
     */
    '/((?!api|about|privacypolicy|termsofservice|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|$).*)',
  ],
};
Enter fullscreen mode Exit fullscreen mode

What the Middleware Does

  • Restricts Access: Blocks access to protected routes for unauthenticated users. Routes like api, about, and static files remain accessible.
  • Handles Redirection: Redirects unauthenticated users to the specified loginPage (/api/auth/login in this case).
  • Returns Users to Their Original Page: With isReturnToCurrentPage: true, users are brought back to the page they were trying to access after successfully logging in.
  • Flexible Matcher Configuration: The matcher property defines which routes should bypass the middleware (e.g., API and static resources). This ensures only relevant routes are protected.

Logic Overview

  • Unauthenticated User: When a user accesses a restricted route, the middleware intercepts the request and redirects them to the login page.
  • Authenticated User: If the user is already authenticated, the middleware allows access to the requested route without interruption.
  • Post-Login Behavior: After logging in, users are automatically redirected to the page they originally tried to access.

This middleware ensures that only authenticated users can access sensitive parts of your app, providing a seamless login experience with smart redirection.

Sync Authenticated Users to Database Using Webhooks

  • Webhooks are HTTP callbacks triggered by specific events in an application (e.g., user.created in Kinde).
  • How Convex Uses Webhooks: Convex exposes HTTP endpoints through httpRouter. These endpoints receive webhook payloads, validate them, and trigger internal processes like mutations or queries.
  • Convex’s HTTPS URL: Each httpRouter route (like /kinde) generates a unique HTTPS endpoint, e.g., https://<your-convex-app>.convex.site/kinde. You provide this URL to Kinde to send webhook events.

Setting up Webhook Configuration in Kinde

  • To create a Webhook in Kinde, go to your Kinde Dashboard and click on Settings:
    Image description

  • Next, scroll down till your see Webhooks and then click on it:

Image description

  • Here I already have a Webhook setup, but if this is your first time, simply click on Add Webhook:

Image description

  • Next, give your webhook a name and description (optional) and then copy and paste your NEXT_PUBLIC_CONVEX_HTTP_URL into the Endpoint URL. Do not forget to add /kinde:

Image description

  • Finally, select the events that you will like to trigger. For this tutorial, we will be selecting the user.created and user.deleted events.

Image description

There are a lot more that you can choose from:

Image description

  • Now unlike Clerk which uses a Webhook Secret to verify webhooks, Kinde automatically signs webhook payloads with a JWT and I will be showing you how that works.

  • You can also use tools like webhook.site to test your webhook and see the type of payload it sends.

  • When testing with your Convex http url, ensure you check Convex's logs for received events.

Image description

Step-by-Step Convex Integration with Kinde Webhooks

When integrating webhooks and JWT validation in serverless environments like Convex, it's essential to use the right libraries for handling JWTs. Convex functions can't use Node.js built-in modules like jsonwebtoken because they rely on features like crypto and stream, which are unavailable in serverless setups.

Why Use jose Instead of jsonwebtoken?

  • Compatibility with Serverless Environments: jsonwebtoken relies on Node.js modules not supported in serverless environments, while jose is designed for such platforms and works without native Node.js dependencies.
  • Browser Compatibility: Unlike jsonwebtoken, which is server-side only, jose can run in both the browser and serverless platforms, making it a more versatile, lightweight choice.

Issue with jsonwebtoken for Kinde Webhooks
In the tutorial on integrating Kinde with Convex webhooks here, the jsonwebtoken package is suggested for JWT verification. However, this method has an issue when using Convex, as Convex doesn't support Node.js built-in modules. Moreover, Kinde doesn't send the authorization token in the Authorization header, which complicates the verification process. As a result, we need to adopt a different approach, using content-type instead of the standard Authorization header.

Here’s how we can implement this with jose for JWT verification:
Step 1: Install jose:

  • First, install the jose library, which will be used to validate the JWTs.
npm install jose
Enter fullscreen mode Exit fullscreen mode

Step 2: Import the Necessary Libraries:

import { httpRouter } from "convex/server";
import { internal } from "./_generated/api";
import { httpAction } from "./_generated/server";
import { jwtVerify, createRemoteJWKSet } from "jose";
Enter fullscreen mode Exit fullscreen mode
  • httpRouter: This is from Convex, and it's used to set up HTTP routes for handling HTTP requests within Convex functions. It will help define how your serverless app handles requests, in this case, webhook events.
  • internal: This refers to your Convex internal API, which allows you to run queries and mutations. You'll use this to interact with your Convex database (e.g., adding, updating or deleting users).
  • httpAction: This is a Convex function wrapper used to define serverless functions that handle HTTP requests. It encapsulates your logic for responding to the requests.
  • jwtVerify and createRemoteJWKSet: These are functions from the jose library. jwtVerify is used to verify the validity of a JWT (JSON Web Token) against a set of keys, while createRemoteJWKSet allows you to retrieve a set of public keys from a URL, which can be used to verify the JWT.

Step 3: Define Types for Kinde Webhook Data

type KindeEventData = {
  user: {
    id: string;
    email: string;
    first_name?: string;
    last_name?: string | null;
    is_password_reset_requested: boolean;
    is_suspended: boolean;
    organizations: {
      code: string;
      permissions: string | null;
      roles: string | null;
    }[];
    phone?: string | null;
    username?: string | null;
    image_url?: string | null;
  };
};

type KindeEvent = {
  type: string;
  data: KindeEventData;
};
Enter fullscreen mode Exit fullscreen mode

Here, we define the types for the event data structure that comes from the Kinde webhook.

  • KindeEventData: Represents the actual data sent by Kinde, including user details like id, email, first_name, last_name, etc.
  • KindeEvent: The overall structure of the webhook event, which includes a type (e.g., user.created, user.deleted) and the associated data (which is of type KindeEventData).

Step 4: Setup the HTTP Router

const http = httpRouter();
Enter fullscreen mode Exit fullscreen mode

This line initializes the HTTP router. This is where you define the routes for handling specific HTTP requests. You can think of it as setting up an endpoint where Kinde can send its webhook events.

Step 5: Define the Webhook Handler

const handleKindeWebhook = httpAction(async (ctx, request) => {
  const event = await validateKindeRequest(request);
  if (!event) {
    return new Response("Invalid request", { status: 400 });
  }

  switch (event.type) {
    case "user.created":
      await ctx.runMutation(internal.users.createUserKinde, {
        kindeId: event.data.user.id,
        email: event.data.user.email,
        username: event.data.user.first_name || ""
      });
      break;
    case "user.deleted":
      const userToDelete = await ctx.runQuery(internal.users.getUserKinde, {
        kindeId: event.data.user.id,
      });

      if (userToDelete) {
        await ctx.runMutation(internal.users.deleteUserKinde, {
          kindeId: event.data.user.id,
        });
      } else {
        console.warn(`No user found to delete with kindeId ${event.data.user.id}.`);
      }
      break;
    default:
      console.warn(`Unhandled event type: ${event.type}`);
  }

  return new Response(null, { status: 200 });
});
Enter fullscreen mode Exit fullscreen mode

This function is responsible for handling Kinde webhook requests. It:

  • Validates the incoming request using the validateKindeRequest function.
  • If the validation fails, it responds with a 400 status code (Bad Request).
  • If the request is valid, it checks the event type (e.g., user.created, user.deleted) and performs appropriate actions:
    • For a user.created event, it calls the createUserKinde mutation to add a new user to the Convex database.
    • For a user.deleted event, it attempts to fetch the user from the database and delete them if found.
  • If the event type is unhandled, it logs a warning.

Step 6: Create the JWT Validation Function

async function validateKindeRequest(request: Request): Promise<KindeEvent | null> {
  try {
    if (request.headers.get("content-type") !== "application/jwt") {
      console.error("Invalid Content-Type. Expected application/jwt");
      return null;
    }

    const token = await request.text(); // JWT is sent as raw text in the body.
    const JWKS_URL = `${process.env.KINDE_ISSUER_URL}/.well-known/jwks.json`;
    const JWKS = createRemoteJWKSet(new URL(JWKS_URL));

    const { payload } = await jwtVerify(token, JWKS);

    if (
      typeof payload === "object" &&
      payload !== null &&
      "type" in payload &&
      "data" in payload
    ) {
      return {
        type: payload.type as string,
        data: payload.data as KindeEventData,
      };
    } else {
      console.error("Payload does not match the expected structure");
      return null;
    }
  } catch (error) {
    console.error("JWT verification failed", error);
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

This function is responsible for validating the JWT in the incoming request. Here’s what it does:

  • Content-Type Check: It ensures the Content-Type is application/jwt. If it's not, the function logs an error and returns null.
  • Extract JWT: The JWT token is extracted directly from the body of the request using request.text(). This is different from many standard setups where the JWT is typically passed in the Authorization header.
  • Get JWK Set: It fetches the JSON Web Key Set (JWKS) from Kinde’s URL (process.env.KINDE_ISSUER_URL). The JWKS is a set of public keys used to verify the JWT’s signature.
  • Verify JWT: It uses jose's jwtVerify function to verify the JWT against the JWKS. If the verification is successful, it checks that the payload contains the expected type and data fields.
  • Return Valid Event: If the JWT is valid, it returns the parsed event with its type and data. Otherwise, it logs the error and returns null.

Step 7: Register the Route

http.route({
  path: "/kinde",
  method: "POST",
  handler: handleKindeWebhook,
});
Enter fullscreen mode Exit fullscreen mode

This registers the /kinde endpoint to handle POST requests. When Kinde sends a webhook to this endpoint, the handleKindeWebhook function will be executed.

Step 8: Export the Router

export default http;
Enter fullscreen mode Exit fullscreen mode

Finally, the HTTP router is exported for use in your Convex application.

You will get a couple errors in your code, this is because we are yet to write out the functions to add a new user to convex after the event is called.

To do this, create a users.ts file in your Convex folder. Then copy and paste the codes below into it:

import { ConvexError, v } from "convex/values";
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";

export const createUserKinde = internalMutation({
  args: {
    kindeId: v.string(),
    email: v.string(),
    username: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
    notificationType: v.optional(v.string()),
    communication_updates: v.optional(v.boolean()), // true or false : default = true
    marketing_updates: v.optional(v.boolean()), // true or false
    social_updates: v.optional(v.boolean()), // true or false
    security_updates: v.optional(v.boolean()), // true or false : default = true
    stripeId: v.optional(v.string())
  },
  handler: async (ctx, args) => {
    try {
      const newUserId = await ctx.db.insert("users", {
        kindeId: args.kindeId,
        email: args.email,
        username: args.username || "",
        imageUrl: args.imageUrl,
        imageStorageId: args.imageStorageId,
        notificationType: "all",
        communication_updates: true,
        marketing_updates: false,
        social_updates: false,
        security_updates: true,
        stripeId: args.stripeId || ""
      });
      const updatedUser = await ctx.db.get(newUserId);

      return updatedUser;
    } catch (error) {
      console.error("Error creating user:", error);
      throw new ConvexError("Failed to create user.");
    }
  }
});

export const getUserKinde = internalQuery({
  args: { kindeId: v.optional(v.string()) },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("kindeId"), args.kindeId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    return user;
  },
});

export const updateUserKinde = internalMutation({
  args: {
    kindeId: v.string(),
    imageUrl: v.optional(v.string()),
    email: v.optional(v.string()),
    username: v.optional(v.string()),
    stripeId: v.optional(v.string())
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("kindeId"), args.kindeId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    const updateFields = {
      ...(args.kindeId !== undefined && { kindeId: args.kindeId }),
      ...(args.imageUrl !== undefined && { imageUrl: args.imageUrl }),
      ...(args.email !== undefined && { email: args.email }),
      ...(args.username !== undefined && { username: args.username }),
      ...(args.stripeId !== undefined && { stripeId: args.stripeId })
    };

    await ctx.db.patch(user._id, updateFields);
    return user._id;
  },
});

export const deleteUserKinde = internalMutation({
  args: { kindeId: v.string() },
  async handler(ctx, args) {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("kindeId"), args.kindeId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    await ctx.db.delete(user._id);
  },
});

export const updateUser = mutation({
  args: {
    userId: v.id("users"),
    email: v.optional(v.string()),
    username: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    imageStorageId: v.optional(v.id("_storage")),
    notificationType: v.optional(v.string()),
    communication_updates: v.optional(v.boolean()), // true or false : default = true
    marketing_updates: v.optional(v.boolean()), // true or false
    social_updates: v.optional(v.boolean()), // true or false
    security_updates: v.optional(v.boolean()), // true or false : default = true
    stripeId: v.optional(v.string())
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("_id"), args.userId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    const updateFields = {
      ...(args.imageUrl !== undefined && { imageUrl: args.imageUrl }),
      ...(args.imageStorageId !== undefined && { imageStorageId: args.imageStorageId }),
      ...(args.notificationType !== undefined && { notificationType: args.notificationType }),
      ...(args.communication_updates !== undefined && { communication_updates: args.communication_updates }),
      ...(args.marketing_updates !== undefined && { marketing_updates: args.marketing_updates }),
      ...(args.social_updates !== undefined && { social_updates: args.social_updates }),
      ...(args.security_updates !== undefined && { security_updates: args.security_updates }),
      ...(args.email !== undefined && { email: args.email }),
      ...(args.username !== undefined && { username: args.username }),
      ...(args.stripeId !== undefined && { stripeId: args.stripeId })
    };

    await ctx.db.patch(args.userId, updateFields);
    return args.userId;
  },
});

export const getUserByKindeId = query({
  args: { kindeId: v.string() },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("kindeId"), args.kindeId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    return user;
  },
});

export const getUserByEmail = query({
  args: { email: v.string() },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("email"), args.email))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    return user;
  },
});

export const getUserByConvexId = query({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("_id"), args.userId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    return user;
  }
});

export const deleteAndUpdateImage = mutation({
  args: {
    userId: v.id("users"),
    oldImageStorageId: v.id('_storage'),
    newImageUrl: v.string(),
    newImageStorageId: v.id("_storage")
  },
  handler: async (ctx, args) => {
    await ctx.storage.delete(args.oldImageStorageId);

    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("_id"), args.userId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    const updateProfileImage = {
      ...(args.newImageUrl !== undefined && { imageUrl: args.newImageUrl }),
      ...(args.newImageStorageId !== undefined && { imageStorageId: args.newImageStorageId })
    };

    await ctx.db.patch(args.userId, updateProfileImage);
  },
});

export const saveNewProfileImage = mutation({
  args: {
    userId: v.id("users"),
    newImageUrl: v.string(),
    newImageStorageId: v.id("_storage")
  },
  handler: async (ctx, args) => {
    const user = await ctx.db
      .query("users")
      .filter((q) => q.eq(q.field("_id"), args.userId))
      .unique();

    if (!user) {
      throw new ConvexError("User not found");
    }

    const updateProfileImage = {
      ...(args.newImageUrl !== undefined && { imageUrl: args.newImageUrl }),
      ...(args.newImageStorageId !== undefined && { imageStorageId: args.newImageStorageId })
    };

    await ctx.db.patch(args.userId, updateProfileImage);
  },
});

export const deleteUser = mutation({
  args: {
    userId: v.id("users"),
  },
  handler: async (ctx, args) => {
    const user = await ctx.db.get(args.userId);

    if (!user) {
      throw new ConvexError("User not found");
    }

    return await ctx.db.delete(args.userId);
  },
});

export const getUrl = mutation({
  args: {
    storageId: v.id("_storage"),
  },
  handler: async (ctx, args) => {
    return await ctx.storage.getUrl(args.storageId);
  },
});

export const getAllUsers = query({
  handler: async (ctx) => {
    return await ctx.db.query('users').order('desc').collect()
  },
});
Enter fullscreen mode Exit fullscreen mode

Now save and run the command:

npx convex dev
Enter fullscreen mode Exit fullscreen mode

This will revalidate your enter convex codebase and push it.

Image description

Test Login and Logout Flows

To test the login and logout functionality of your application, follow these steps:

  • Navigate to the app directory and open the page.tsx file.
  • Locate and double-click the Hero component file.
  • Replace the existing code with the snippet below.
"use client";

import { Button } from "@/components/ui/button";
import { ArrowRight, FileUp, LogIn, LogOut } from "lucide-react";
import Link from "next/link";
import {
  RegisterLink,
  LoginLink,
  LogoutLink,
} from "@kinde-oss/kinde-auth-nextjs/components";
import { useKindeBrowserClient } from "@kinde-oss/kinde-auth-nextjs";

export async function Hero() {
  const { user } = useKindeBrowserClient();

  return (
    <div className="relative isolate pt-14">
      <div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80">
        <div className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-primary to-secondary opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" />
      </div>

      <div className="py-24 sm:py-32 lg:pb-40">
        <div className="mx-auto max-w-7xl px-6 lg:px-8">
          <div className="mx-auto max-w-2xl text-center">
            <h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
              Share Files Securely with Anyone, Anywhere
            </h1>
            <p className="mt-6 text-lg leading-8 text-muted-foreground">
              The fastest and most secure way to share your files. No signup
              required for basic sharing. Enterprise-grade encryption for all
              your files.
            </p>

            {user ? (
              <div className="mt-10 flex items-center justify-center gap-x-6">
                <Button size="lg" variant="outline" asChild>
                  <Link href="/dashboard">
                    <FileUp className="mr-2 h-4 w-4" />
                    Upload Now
                  </Link>
                </Button>
                <LogoutLink>
                  <Button size="lg" variant="outline">
                    <LogOut className="mr-2 h-4 w-4" />
                    Sign in
                  </Button>
                </LogoutLink>
              </div>
            ) : (
              <div className="mt-10 flex items-center justify-center gap-x-6">
                <RegisterLink>
                  <Button size="lg">
                    Get Started
                    <ArrowRight className="ml-2 h-4 w-4" />
                  </Button>
                </RegisterLink>
                <LoginLink>
                  <Button size="lg" variant="outline">
                    <LogIn className="mr-2 h-4 w-4" />
                    Sign in
                  </Button>
                </LoginLink>
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

This code dynamically checks the user's authentication state and renders appropriate actions for logged-in and logged-out users. While testing, monitor your logs and the users table in Convex to verify behavior.

Fetch User Data from Convex

To fetch user data from Convex into your application, follow these steps:

  • Navigate to the app directory and locate the root folder.
  • Open the dashboard folder, then access the page.tsx file.
  • Replace the existing code in the file with the snippet provided below.
"use client";

import { AppSidebar } from "@/components/app-sidebar"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar"
import { api } from "@/convex/_generated/api"
import { useQuery } from "convex/react"
import {useKindeBrowserClient} from "@kinde-oss/kinde-auth-nextjs";

export default function Page() {
  const { user, isAuthenticated, getPermissions, getPermission } = useKindeBrowserClient();

  const profile = useQuery(api.users.getUserByKindeId, {
    kindeId: user?.id as string
  });

  return (
    <SidebarProvider>
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
          <div className="flex items-center gap-2 px-4">
            <SidebarTrigger className="-ml-1" />
            <Separator orientation="vertical" className="mr-2 h-4" />
            <Breadcrumb>
              <BreadcrumbList>
                <BreadcrumbItem className="hidden md:block">
                  <BreadcrumbLink href="#">
                    Building Your Application
                  </BreadcrumbLink>
                </BreadcrumbItem>
                <BreadcrumbSeparator className="hidden md:block" />
                <BreadcrumbItem>
                  <BreadcrumbPage>Data Fetching {profile?.username}</BreadcrumbPage>
                </BreadcrumbItem>
              </BreadcrumbList>
            </Breadcrumb>
          </div>
        </header>
        <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
          <div className="grid auto-rows-min gap-4 md:grid-cols-3">
            <div className="aspect-video rounded-xl bg-muted/50" />
            <div className="aspect-video rounded-xl bg-muted/50" />
            <div className="aspect-video rounded-xl bg-muted/50" />
          </div>
          <div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Key Features

  • useQuery Hook: Fetches user data from the Convex database by using the user's Kinde ID.
  • Kinde Integration: Utilizes useKindeBrowserClient to manage user authentication and permissions.
  • Dynamic Rendering: Displays the username (profile?.username) fetched from Convex in the breadcrumb navigation.

RBAC (or role-based access control) with Kinde

Role-Based Access Control (RBAC) is a critical authorization mechanism that determines users' level of access based on their roles within an organization. By assigning specific permissions to roles, RBAC ensures that users can only perform actions they're authorized for. This part of the tutorial walks you through implementing RBAC using Kinde for our application.

In our scenario, the application allows:

  • Members to upload files but not delete them.
  • Admins to delete files, both their own and others' within the organization.

By following this guide, you'll learn to manage roles and permissions effectively, both on the frontend (hiding UI elements) and the backend (enforcing restrictions).

Step 1: Setting Up Permissions in Kinde
Permissions define the actions users can perform. Let’s start by creating a delete:file permission.

  • Access the Kinde Dashboard
    • Log in to your Kinde Dashboard.
    • Navigate to Settings in the sidebar and select User Management.

Image description

  • Add a Permission
    • Click on Permissions in the User Management section.
    • Click Add Permission.

Image description

  • Add details of a permission and then save
    • Name: Delete File (or any descriptive name).
    • Description: Leave blank or add and optional context.
    • Key: delete:file.

Image description

Step 2: Creating Roles for Users
Roles group permissions together and are assigned to users based on their responsibilities. Let’s create an admin role that includes the delete:file permission.

  • Add a Role
    • Go to the Users section on the dashboard.
    • Click on a user.

Image description

  • Next, click on Permissions

Image description

  • Click Add Role.
    • Name: admin.
    • Key: admin.
    • Permissions: Select delete:file.
  • Save the role.

Image description

Image description

Step 3: Assigning Roles to Users
Now that the admin role is created, assign it to specific users.

  • Assign the Role to a User
    • Return to the Users section.
    • Select the user you want to make an admin.
    • Toggle the switch for the Admin role to Active.
    • Save the changes

Image description

Step 4: Verifying Permissions
To confirm the user’s permissions:

  • Go back to the Users section and select the user.
  • View the Permissions tab to see that the delete:file permission is now linked to their role.

Image description

Final Thoughts

This tutorial provides a detailed guide to integrating Kinde and Convex into a Next.js application to build a secure, scalable, and user-friendly file-sharing platform with features like passwordless authentication, real-time data synchronization, and role-based access control (RBAC).

Highlights:

1. Authentication with Kinde:

  • Seamlessly integrate passwordless sign-ins and support social logins (Google and Facebook).
  • Use Kinde's middleware to protect routes and manage secure user sessions.
  • Configure webhooks to synchronize user authentication events with Convex in real time.

2. Backend with Convex:

  • Leverage Convex's real-time database and serverless architecture to manage user data.
  • Define schemas, queries, and mutations to store and retrieve user information dynamically.
  • Utilize Convex's HTTP routing for webhook integration and automate user data updates.

3. Role-Based Access Control (RBAC):

  • Setup RBAC using Kinde to restrict user actions based on roles.
  • Create permissions (e.g., delete:file) and roles (e.g., admin) directly in the Kinde dashboard.

4. Practical Implementation:

  • Develop a feature-rich user interface with dynamic user state handling for logged-in and logged-out experiences.
  • Use webhooks and JWT validation to synchronize authentication events securely.
  • Test login/logout flows and fetch user data from Convex for personalized dashboards.

5. Developer-Friendly Setup:

  • Utilize a pre-configured Next.js starter repository with TypeScript, TailwindCSS, and Shadcn UI components.
  • Comprehensive step-by-step instructions for setting up Kinde and Convex, ensuring seamless integration.

Access the Full Codebase

Want to explore the complete implementation? Check out the fully implemented codebase on GitHub. Feel free to clone, experiment, and adapt it to your needs. Contributions and stars are always welcome!

GitHub logo sholajegede / kinde-convex-starter

The Kinde and Convex Starter App is a fully pre-configured Next.js application designed to help you quickly integrate secure authentication, role-based access control (RBAC), and user data management using Kinde and Convex.

Kinde-Convex File Sharing App: Part 1

A fully pre-configured Next.js application to kickstart your development with Kinde and Convex, enabling secure authentication, real-time data syncing, and role-based access control (RBAC). This app serves as a foundational template for building scalable, secure, and user-friendly file-sharing applications.

🚀Features

  • Passwordless Authentication: Enable seamless logins with Kinde's secure authentication services.
  • Social Login Integration: Support for Google, Facebook, and GitHub sign-ins.
  • Real-Time Data Management: Powered by Convex, ensuring real-time updates between the frontend and backend.
  • Role-Based Access Control (RBAC): Assign and manage roles like Admins and Members to restrict or enable access to features.
  • Webhook Integration: Sync user authentication events to your database in real-time.
  • Pre-Styled UI Components: Built using Shadcn and TailwindCSS for a responsive and intuitive design.

🎓What You'll Learn

This repository demonstrates how to:

  • Integrate Kinde into your Next.js project for authentication.
  • Configure Convex for database and backend API functions.
  • Implement RBAC…




What’s Next?

In Part 2, the focus shifts to implementing RBAC in a production-ready file-sharing application. You’ll learn to configure advanced roles and permissions, apply granular access controls, and enforce RBAC across API endpoints and frontend workflows. This ensures secure and scalable authorization for real-world use cases.

With this tutorial, you're equipped to build applications with enterprise-grade security with Kinde, real-time data updates, and precise access control mechanisms. Part 2 promises to take this foundation to the next level! 🚀

We’d love to hear from you!

Got thoughts, questions, or suggestions? Drop them in the comments below or reach out to me directly on GitHub. Your feedback helps to improve and ensures we cover everything you need to succeed. Let us know what you think—let’s build together!

Top comments (1)

Collapse
 
prodevstaff profile image
ProDev. Staff