From 422693f80867deaecb3ce74beb8317b909b5320a Mon Sep 17 00:00:00 2001 From: siddiquiaffan Date: Sat, 17 Aug 2024 15:19:37 +0530 Subject: [PATCH] feat: Implemented role based access control --- src/components/auth/withPermissionCheck.tsx | 52 ++++++++++++++++++ src/lib/auth/check-access.ts | 60 +++++++++++++++++++++ src/lib/auth/index.ts | 1 + src/server/db/schema.ts | 5 ++ 4 files changed, 118 insertions(+) create mode 100644 src/components/auth/withPermissionCheck.tsx create mode 100644 src/lib/auth/check-access.ts diff --git a/src/components/auth/withPermissionCheck.tsx b/src/components/auth/withPermissionCheck.tsx new file mode 100644 index 0000000..07873ca --- /dev/null +++ b/src/components/auth/withPermissionCheck.tsx @@ -0,0 +1,52 @@ +/* +@Author: siddiquiaffan +*/ + +import React from 'react' +import type { Role } from '@/server/db/schema' +import { checkAccess } from '@/lib/auth/check-access' +import { redirect } from 'next/navigation' + +type Options = ({ + Fallback: React.FC +}) | ({ + redirect: string +}) + +const DefaultFallback = () =>
Permission denied
+ +/** + * + * A high order component which takes a component and roles as arguments and returns a new component. + * @example + * ``` + * withPermissionCheck( + * MyComponent, + * ['user', 'moderator'], + * { Fallback: () =>
Permission denied
} + * ) + * ``` + */ + +// eslint-disable-next-line +const withPermissionCheck = >(Component: React.FC, roles: Role[], options?: Options): React.FC => { + + return async (props: T) => { + + const hasPermission = await checkAccess(roles) + + if (!hasPermission) { + if (options && 'redirect' in options) { + redirect(options.redirect) + } else { + const Fallback = options?.Fallback ?? DefaultFallback + return + } + } + + return + } +}; + + +export default withPermissionCheck diff --git a/src/lib/auth/check-access.ts b/src/lib/auth/check-access.ts new file mode 100644 index 0000000..7182731 --- /dev/null +++ b/src/lib/auth/check-access.ts @@ -0,0 +1,60 @@ +/* +@Author: siddiquiaffan +@Desc: Utility functions for role based access +*/ + +import { type Role } from "@/server/db/schema"; +import { validateRequest } from './validate-request' +import { cache } from "react"; + +export async function uncachedCheckAccess( + role: Role | Role[], + { + method, + }: { + method: "some" | "every"; + } = { method: "some" }, +): Promise { + const { user } = await validateRequest(); + if (!user) { + return false; + } + + // admin can access everything + if (user.roles?.includes("admin")) { + return true; + } + + if (Array.isArray(role)) { + return role[method]((r: string) => user.roles?.includes(r as Role)); + } + + return !!user.roles?.includes(role as Role); +} + +/** + * Check if the user has access + */ +export const checkAccess = cache(uncachedCheckAccess); + + +// ============== { Separate methods for each role type } ============== +export async function isModerator(): Promise { + return checkAccess("moderator"); +} + +export async function isAdmin(): Promise { + return checkAccess("admin"); +} + +export async function isContentCreator(): Promise { + return checkAccess("content-creator"); +} + +export async function isOnlyUser(): Promise { + const { user } = await validateRequest(); + if (!user) { + return false; + } + return user.roles?.length === 1 && user.roles[0] === "user"; +} diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 1be9c4e..0a4570e 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -24,6 +24,7 @@ export const lucia = new Lucia(adapter, { avatar: attributes.avatar, createdAt: attributes.createdAt, updatedAt: attributes.updatedAt, + roles: attributes.roles, }; }, sessionExpiresIn: new TimeSpan(30, "d"), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 8574060..af3b72c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -7,11 +7,14 @@ import { text, timestamp, varchar, + json, } from "drizzle-orm/pg-core"; import { DATABASE_PREFIX as prefix } from "@/lib/constants"; export const pgTable = pgTableCreator((name) => `${prefix}_${name}`); +export type Role = 'user' | 'admin' | 'moderator' | 'content-creator'; + export const users = pgTable( "users", { @@ -27,6 +30,7 @@ export const users = pgTable( stripeCurrentPeriodEnd: timestamp("stripe_current_period_end"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date" }).$onUpdate(() => new Date()), + roles: json("roles").$type().default(['user']), }, (t) => ({ emailIdx: index("user_email_idx").on(t.email), @@ -106,3 +110,4 @@ export const postRelations = relations(posts, ({ one }) => ({ export type Post = typeof posts.$inferSelect; export type NewPost = typeof posts.$inferInsert; +export type PostWithUser = Post & { user: User }; \ No newline at end of file