Compare commits

...

4 Commits

Author SHA1 Message Date
166e00c284 feat(auth): add role-based access control for dashboard
Implement admin, moderator, and user roles with permissions for dashboard access, libraries, and extensions. Added database schema fields for user roles and bans, integrated Better Auth admin plugin, and created TRPC middleware for permission checks. Updated client-side auth to support role-based features.
2025-10-05 12:55:31 +00:00
e4fd30a8fd feat(config): add auth generation script and turbo task
Reorganized package.json structure by moving devDependencies to the top and adding packageManager field. Introduced new script "auth:generate" for authentication key generation. Updated turbo.json with formatted structure and new "auth:generate" task for persistent execution.
2025-10-05 12:55:04 +00:00
d7a572f496 feat(dashboard): add permission check for dashboard access
Add conditional rendering in dashboard route to check admin permissions using authClient. If access is denied, display an empty state with access denied message and link back to home. This enhances security by restricting dashboard access to authorized users.
2025-10-05 12:53:15 +00:00
9c8c1f1127 refactor(ui): refactor UserMenu to use Link for dashboard navigation
Replace programmatic navigation in UserMenu with Link component for better integration with TanStack Router, ensuring the dropdown closes on navigation
2025-10-05 10:33:39 +00:00
11 changed files with 198 additions and 34 deletions

View File

@@ -1,3 +1,10 @@
export {
accessControl,
adminRole,
moderatorRole,
statement,
userRole,
} from "./src/lib/auth/permissions";
export * as Models from "./src/models";
export * from "./src/providers/metadata";
export * from "./src/services/extensions/baseExtension";

View File

@@ -17,6 +17,10 @@ export const user = sqliteTable("user", {
.notNull(),
username: text("username").unique(),
displayUsername: text("display_username"),
role: text("role"),
banned: integer("banned", { mode: "boolean" }).default(false),
banReason: text("ban_reason"),
banExpires: integer("ban_expires", { mode: "timestamp" }),
});
export const session = sqliteTable("session", {
@@ -34,6 +38,7 @@ export const session = sqliteTable("session", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
});
export const account = sqliteTable("account", {

View File

@@ -1,8 +1,14 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { username } from "better-auth/plugins";
import { admin, username } from "better-auth/plugins";
import { database } from "../db";
import * as schema from "../db/schema/auth";
import {
accessControl,
adminRole,
moderatorRole,
userRole,
} from "./auth/permissions";
const auth = betterAuth({
database: drizzleAdapter(database, {
@@ -20,7 +26,18 @@ const auth = betterAuth({
httpOnly: true,
},
},
plugins: [username()],
plugins: [
username(),
admin({
ac: accessControl,
roles: {
adminRole,
moderatorRole,
userRole,
},
defaultRole: "user",
}),
],
});
export default auth;

View File

@@ -0,0 +1,29 @@
import { createAccessControl } from "better-auth/plugins/access";
import { adminAc, defaultStatements } from "better-auth/plugins/admin/access";
export const statement = {
...defaultStatements,
dashboard: ["access"],
libraries: ["create", "read", "update", "delete"],
extensions: ["create", "read", "update", "delete"],
} as const;
export const accessControl = createAccessControl(statement);
export const adminRole = accessControl.newRole({
...adminAc.statements,
dashboard: ["access"],
libraries: ["create", "read", "update", "delete"],
extensions: ["create", "read", "update", "delete"],
});
export const moderatorRole = accessControl.newRole({
dashboard: ["access"],
libraries: ["read"],
extensions: ["read"],
});
export const userRole = accessControl.newRole({
libraries: ["read"],
extensions: ["read"],
});

View File

@@ -0,0 +1,34 @@
import { TRPCError } from "@trpc/server";
import auth from "@/lib/auth";
import { t } from "@/lib/trpc";
export const dashboardMiddleware = t.middleware(async ({ ctx, next }) => {
const session = await auth.api.getSession({
headers: ctx.requestContext.req.raw.headers,
});
if (!session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required. Please log in to continue.",
});
}
const hasPermission = await auth.api.userHasPermission({
body: {
userId: session.user.id,
permissions: {
dashboard: ["access"],
},
},
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to access the dashboard.",
});
}
return next({ ctx: { ...ctx, userSession: session } });
});

View File

@@ -1,6 +1,6 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { LayoutDashboard, LogOutIcon } from "lucide-react";
import React from "react";
@@ -77,15 +77,11 @@ export default function UserMenu() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
navigate({
to: "/home/dashboard",
});
}}
>
<LayoutDashboard />
Dashboard
<DropdownMenuItem asChild>
<Link to="/dashboard" onClick={() => setOpen(false)}>
<LayoutDashboard />
Dashboard
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -1,7 +1,21 @@
import { usernameClient } from "better-auth/client/plugins";
import { adminRole, moderatorRole, statement, userRole } from "@nontara/server";
import { adminClient, usernameClient } from "better-auth/client/plugins";
import { createAccessControl } from "better-auth/plugins/access";
import { createAuthClient } from "better-auth/react";
const ac = createAccessControl(statement);
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_SERVER_URL,
plugins: [usernameClient()],
plugins: [
usernameClient(),
adminClient({
ac,
roles: {
admin: adminRole,
moderator: moderatorRole,
user: userRole,
},
}),
],
});

View File

@@ -31,6 +31,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
Sidebar,
SidebarContent,
@@ -59,6 +67,16 @@ export const Route = createFileRoute("/dashboard")({
to: "/login",
});
}
const { data: hasPermission } = await authClient.admin.hasPermission({
permissions: {
dashboard: ["access"],
},
});
return {
hasAccess: hasPermission ?? false,
};
},
});
@@ -334,6 +352,35 @@ export function DashboardHeader({ children }: PropsWithChildren) {
}
function RouteComponent() {
const { hasAccess } = Route.useRouteContext();
if (!hasAccess) {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Settings />
</EmptyMedia>
<EmptyTitle>Access Denied</EmptyTitle>
<EmptyDescription>
You do not have permission to access the dashboard. Please contact
an administrator if you believe this is an error.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<Link to="/home">
<ArrowLeft className="mr-2 size-4" />
Back to Home
</Link>
</Button>
</EmptyContent>
</Empty>
</div>
);
}
return (
<SidebarProvider>
<Sidebar>

View File

@@ -1,12 +1,11 @@
{
"name": "nontara",
"devDependencies": {
"turbo": "^2.5.4",
"@biomejs/biome": "^2.2.0"
},
"packageManager": "bun@1.2.22",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*",
"extensions/*"
],
"scripts": {
"check": "biome check --write .",
"dev": "turbo dev",
@@ -18,12 +17,13 @@
"db:push": "turbo -F @nontara/server db:push",
"db:studio": "turbo -F @nontara/server db:studio",
"db:generate": "turbo -F @nontara/server db:generate",
"db:migrate": "turbo -F @nontara/server db:migrate"
"db:migrate": "turbo -F @nontara/server db:migrate",
"auth:generate": "turbo -F @nontara/server auth:generate"
},
"dependencies": {},
"devDependencies": {
"turbo": "^2.5.4",
"@biomejs/biome": "^2.2.0"
},
"packageManager": "bun@1.2.22"
}
"type": "module",
"workspaces": [
"apps/*",
"packages/*",
"extensions/*"
]
}

View File

@@ -3,15 +3,26 @@
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"]
"dependsOn": [
"^build"
],
"inputs": [
"$TURBO_DEFAULT$",
".env*"
],
"outputs": [
"dist/**"
]
},
"lint": {
"dependsOn": ["^lint"]
"dependsOn": [
"^lint"
]
},
"check-types": {
"dependsOn": ["^check-types"]
"dependsOn": [
"^check-types"
]
},
"dev": {
"cache": false,
@@ -32,6 +43,10 @@
"db:generate": {
"cache": false,
"persistent": true
},
"auth:generate": {
"cache": false,
"persistent": true
}
}
}
}