├── .nvmrc ├── .node-version ├── .husky └── pre-commit ├── public ├── favicon.ico └── logo │ ├── logo-dark.svg │ └── logo-light.svg ├── src ├── actions │ ├── tools │ │ ├── index.ts │ │ └── delete.ts │ ├── permissions │ │ ├── index.ts │ │ └── delete.ts │ ├── roles │ │ ├── index.ts │ │ ├── delete.ts │ │ ├── remove-user.ts │ │ ├── remove-permission.ts │ │ ├── add-users.ts │ │ └── add-tools.ts │ ├── users │ │ ├── get-current-user.ts │ │ ├── delete-user.ts │ │ ├── remove-role.ts │ │ ├── delete-account.ts │ │ └── update-tool-favorite.ts │ ├── auth │ │ ├── confirm-email.ts │ │ └── change-email.ts │ └── feedback.ts ├── app │ ├── (tools) │ │ ├── tools │ │ │ ├── blog │ │ │ │ ├── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── feedbacks │ │ │ │ ├── layout.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── empty-state.tsx │ │ │ │ │ ├── data-table-column-header.tsx │ │ │ │ │ ├── feedbacks-table.tsx │ │ │ │ │ └── data-table-utils-ssr.tsx │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── tool-empty-state.tsx │ │ │ │ ├── add-tool-button.tsx │ │ │ │ ├── tools-section.tsx │ │ │ │ └── card-skeleton.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── api │ │ ├── auth │ │ │ ├── [...nextauth] │ │ │ │ └── route.ts │ │ │ ├── reset-password │ │ │ │ ├── route.ts │ │ │ │ └── [token] │ │ │ │ │ └── route.ts │ │ │ ├── confirm │ │ │ │ └── route.ts │ │ │ └── register │ │ │ │ └── route.ts │ │ ├── openapi │ │ │ └── route.ts │ │ ├── docs │ │ │ └── page.tsx │ │ ├── tools │ │ │ └── route.ts │ │ └── permissions │ │ │ └── options │ │ │ └── route.ts │ ├── (admin) │ │ ├── admin │ │ │ ├── roles │ │ │ │ ├── [roleId] │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── (routes) │ │ │ │ │ │ ├── tools │ │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── users │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ │ ├── users-empty-state-table.tsx │ │ │ │ │ │ │ │ ├── add-user-button.tsx │ │ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ │ │ ├── add │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── permissions │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ ├── permissions-empty-state-table.tsx │ │ │ │ │ │ │ └── add-permission-button.tsx │ │ │ │ │ │ │ ├── add │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── _components │ │ │ │ │ │ └── delete-role-button.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── roles-empty-state-table.tsx │ │ │ │ │ ├── roles-table.tsx │ │ │ │ │ └── create-role-button.tsx │ │ │ │ ├── new │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── users │ │ │ │ ├── [userId] │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── (routes) │ │ │ │ │ │ ├── roles │ │ │ │ │ │ │ ├── _components │ │ │ │ │ │ │ │ ├── roles-empty-state-table.tsx │ │ │ │ │ │ │ │ ├── add-role-button.tsx │ │ │ │ │ │ │ │ └── columns.tsx │ │ │ │ │ │ │ ├── add │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── logs │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── actions │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── metadata │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── profile │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── _components │ │ │ │ │ │ └── delete-user-button.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── users-empty-state-table.tsx │ │ │ │ │ └── create-user-button.tsx │ │ │ │ ├── new │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── tools │ │ │ │ ├── [toolId] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── tools-empty-state-table.tsx │ │ │ │ │ ├── tools-table.tsx │ │ │ │ │ ├── create-tool-button.tsx │ │ │ │ │ └── delete-tool-button.tsx │ │ │ │ ├── new │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── permissions │ │ │ │ ├── [permissionId] │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── _components │ │ │ │ │ │ └── delete-permission-button.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── permissions-empty-state-table.tsx │ │ │ │ │ ├── permissions-table.tsx │ │ │ │ │ └── create-permission-button.tsx │ │ │ │ ├── new │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── tokens │ │ │ │ ├── _components │ │ │ │ │ ├── tokens-empty-state-table.tsx │ │ │ │ │ ├── create-token-button.tsx │ │ │ │ │ ├── copy-button.tsx │ │ │ │ │ └── token-display.tsx │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── card-kpi-loading.tsx │ │ │ │ └── card-kpi.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── (root) │ │ ├── about │ │ │ └── page.tsx │ │ ├── profile │ │ │ └── page.tsx │ │ ├── settings │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── (auth) │ │ └── auth │ │ │ ├── confirm │ │ │ └── [token] │ │ │ │ ├── page.tsx │ │ │ │ └── loading.tsx │ │ │ ├── change-email │ │ │ └── [token] │ │ │ │ ├── page.tsx │ │ │ │ └── loading.tsx │ │ │ └── logout │ │ │ ├── logout.tsx │ │ │ └── page.tsx │ ├── (home) │ │ └── layout.tsx │ ├── not-found.tsx │ ├── (healthcheck) │ │ └── healthcheck │ │ │ └── page.tsx │ └── layout.tsx ├── lib │ ├── auth │ │ ├── index.ts │ │ ├── hash-token.ts │ │ └── create-token-api.ts │ ├── api │ │ ├── index.ts │ │ ├── api-error.ts │ │ ├── parse-request-body.ts │ │ ├── get-pagination.ts │ │ └── get-id-input-or-throw.ts │ ├── activity.ts │ ├── swr │ │ ├── use-tools.ts │ │ └── fetcher.ts │ ├── hooks │ │ ├── use-debounce.ts │ │ ├── use-copy-to-clipboard.tsx │ │ ├── use-operating-system.tsx │ │ ├── use-media-query.tsx │ │ └── use-url-params.ts │ ├── prismadb.ts │ ├── openapi │ │ ├── roles │ │ │ ├── index.ts │ │ │ ├── list-roles.ts │ │ │ └── read-role.ts │ │ ├── users │ │ │ ├── index.ts │ │ │ ├── list-users.ts │ │ │ └── delete-user.ts │ │ ├── tokens │ │ │ ├── index.ts │ │ │ └── list-tokens.ts │ │ ├── permissions │ │ │ ├── index.ts │ │ │ └── list-permissions.ts │ │ └── responses.ts │ ├── validate-schema-action.ts │ ├── jwt.ts │ ├── mail.ts │ └── zod │ │ └── utils.ts ├── components │ ├── admin │ │ ├── table-loading.tsx │ │ ├── section-loading.tsx │ │ └── sidebar-nav.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── label.tsx │ │ ├── data-tables │ │ │ ├── server-side │ │ │ │ └── data-table-search.tsx │ │ │ ├── data-table-fallback.tsx │ │ │ ├── empty-state.tsx │ │ │ ├── data-table-filters.tsx │ │ │ └── data-table-view-options.tsx │ │ ├── separator.tsx │ │ ├── textarea.tsx │ │ ├── input.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── checkbox.tsx │ │ ├── radio-group.tsx │ │ ├── hover-card.tsx │ │ └── toggle.tsx │ ├── max-width-wrapper.tsx │ ├── navbar │ │ ├── login-button.tsx │ │ ├── user-avatar.tsx │ │ └── navbar.tsx │ ├── back-link-button.tsx │ ├── providers.tsx │ ├── health-status-button.tsx │ ├── auth │ │ └── auth-template.tsx │ ├── copy-clipboard-dropdown-menu-item.tsx │ ├── site-footer.tsx │ ├── 404.tsx │ └── copy-clipboard-button.tsx ├── schemas │ ├── feedbacks.ts │ ├── tools.ts │ └── activity-logs.ts ├── data │ ├── tools.ts │ ├── feedback.ts │ └── permission.ts ├── types │ ├── types.ts │ ├── next-auth.d.ts │ └── token.ts ├── middleware.ts └── emails │ ├── reset-email.tsx │ ├── confirm-email.tsx │ └── update-email.tsx ├── .prettierignore ├── postcss.config.mjs ├── prisma ├── migrations │ └── migration_lock.toml ├── schema.prisma ├── models │ ├── activity.prisma │ ├── feedback.prisma │ ├── tokenPermission.prisma │ ├── permission.prisma │ ├── token.prisma │ └── tool.prisma └── seed.ts ├── .editorconfig ├── next.config.ts ├── components.json ├── .vscode ├── settings.json └── extensions.json ├── .gitignore ├── .env.example ├── tsconfig.json ├── .prettierrc ├── compose.yaml ├── eslint.config.mjs └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.12.0 -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.12.0 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ezeparziale/quark/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/actions/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create" 2 | export * from "./delete" 3 | export * from "./update" 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build 5 | src/components/ui/* 6 | !src/components/ui/data-tables/ -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | } 4 | 5 | export default config 6 | -------------------------------------------------------------------------------- /src/actions/permissions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create" 2 | export * from "./delete" 3 | export * from "./update" 4 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/blog/page.tsx: -------------------------------------------------------------------------------- 1 | export default function BlogPage() { 2 | return
My blog page
3 | } 4 | -------------------------------------------------------------------------------- /src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./admin" 2 | export * from "./create-token-api" 3 | export * from "./hash-token" 4 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth" 2 | 3 | export const { GET, POST } = handlers 4 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api-error" 2 | export * from "./get-id-input-or-throw" 3 | export * from "./get-pagination" 4 | export * from "./parse-request-body" 5 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/[roleId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import SectionLoading from "@/components/admin/section-loading" 2 | 3 | export default function LoadingEditRolePage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/[userId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import SectionLoading from "@/components/admin/section-loading" 2 | 3 | export default function LoadingEditUserPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/app/api/openapi/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | import { document } from "@/lib/openapi" 4 | 5 | export function GET() { 6 | return NextResponse.json({ ...document }) 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/[roleId]/(routes)/tools/loading.tsx: -------------------------------------------------------------------------------- 1 | import SectionLoading from "@/components/admin/section-loading" 2 | 3 | export default function LoadingRolesAdminToolsPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/api/api-error.ts: -------------------------------------------------------------------------------- 1 | export class ApiError extends Error { 2 | public readonly code 3 | constructor({ message, code }: { message: string; code: number }) { 4 | super(message) 5 | this.code = code 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/(root)/about/page.tsx: -------------------------------------------------------------------------------- 1 | export default function About() { 2 | return ( 3 | <> 4 |

5 | About 6 |

7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/actions/roles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./add-permissions" 2 | export * from "./add-users" 3 | export * from "./create" 4 | export * from "./delete" 5 | export * from "./remove-permission" 6 | export * from "./remove-user" 7 | export * from "./update" 8 | -------------------------------------------------------------------------------- /src/app/(root)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | export default function About() { 2 | return ( 3 | <> 4 |

5 | Profile 6 |

7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next" 2 | 3 | import "./src/env" 4 | 5 | const nextConfig: NextConfig = { 6 | logging: { 7 | fetches: { 8 | fullUrl: true, 9 | }, 10 | }, 11 | } 12 | 13 | export default nextConfig 14 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/feedbacks/layout.tsx: -------------------------------------------------------------------------------- 1 | import MaxWidthWrapper from "@/components/max-width-wrapper" 2 | 3 | export default function FeedbackLayout({ children }: { children: React.ReactNode }) { 4 | return {children} 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/auth/hash-token.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto" 2 | import "server-only" 3 | 4 | export async function hashToken(token: string): Promise { 5 | const hashedToken = createHash("sha256").update(token).digest("hex") 6 | return hashedToken 7 | } 8 | -------------------------------------------------------------------------------- /src/components/admin/table-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export default function TableLoading() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/activity.ts: -------------------------------------------------------------------------------- 1 | import "server-only" 2 | 3 | import prismadb from "./prismadb" 4 | 5 | export async function logActivity(userId: number, action: string) { 6 | await prismadb.activityLog.create({ 7 | data: { 8 | userId, 9 | action, 10 | }, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/schemas/feedbacks.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const feedbackSchema = z.object({ 4 | feedback: z 5 | .string() 6 | .min(1, "Please provide your feedback") 7 | .max(1000, "Too long, please shorten your feedback"), 8 | nps: z.string().optional(), 9 | }) 10 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFound404 from "@/components/404" 2 | 3 | export default function NotFound() { 4 | return ( 5 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/blog/layout.tsx: -------------------------------------------------------------------------------- 1 | import MaxWidthWrapper from "@/components/max-width-wrapper" 2 | 3 | export default function BlogLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | {children} 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFound404 from "@/components/404" 2 | 3 | export default function RoleNotFound() { 4 | return ( 5 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/admin/section-loading.tsx: -------------------------------------------------------------------------------- 1 | import { PageSection } from "../page-header" 2 | import TableLoading from "./table-loading" 3 | 4 | export default function SectionLoading() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/data/tools.ts: -------------------------------------------------------------------------------- 1 | import "server-only" 2 | 3 | import prismadb from "@/lib/prismadb" 4 | 5 | export const getToolById = async (id: number) => { 6 | try { 7 | const tool = await prismadb.tool.findUnique({ where: { id } }) 8 | 9 | return tool 10 | } catch { 11 | return null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/tools/[toolId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFound404 from "@/components/404" 2 | 3 | export default function ToolNotFound() { 4 | return ( 5 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(auth)/auth/confirm/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import { confirmEmail } from "@/actions/auth/confirm-email" 2 | 3 | type Params = Promise<{ token: string }> 4 | 5 | export default async function ConfirmTokenPage(props: { params: Params }) { 6 | const params = await props.params 7 | await confirmEmail({ token: params.token }) 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/loading.tsx: -------------------------------------------------------------------------------- 1 | import TableLoading from "@/components/admin/table-loading" 2 | import { PageHeader } from "@/components/page-header" 3 | 4 | export default function LoadingRolesAdminPage() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/loading.tsx: -------------------------------------------------------------------------------- 1 | import TableLoading from "@/components/admin/table-loading" 2 | import { PageHeader } from "@/components/page-header" 3 | 4 | export default function LoadingUsersAdminPage() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(auth)/auth/change-email/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import { changeEmail } from "@/actions/auth/change-email" 2 | 3 | type Params = Promise<{ token: string }> 4 | 5 | export default async function ChangeEmailTokenPage(props: { params: Params }) { 6 | const params = await props.params 7 | await changeEmail({ token: params.token }) 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/swr/use-tools.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr" 2 | 3 | import { ITool } from "@/types/types" 4 | 5 | import { fetcher } from "./fetcher" 6 | 7 | export default function useTools() { 8 | const { data, error, isLoading } = useSWR("/api/tools", fetcher) 9 | 10 | return { tools: data || [], error, isLoading } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(root)/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { PageHeader } from "@/components/page-header" 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | 7 | {children} 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/api/parse-request-body.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from "./api-error" 2 | 3 | export const parseRequestBody = async (req: Request) => { 4 | try { 5 | return await req.json() 6 | } catch { 7 | throw new ApiError({ 8 | message: "Invalid JSON format in request body.", 9 | code: 400, 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | previewFeatures = ["relationJoins"] 7 | } 8 | 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | } 13 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/permissions/[permissionId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFound404 from "@/components/404" 2 | 3 | export default function PermissionNotFound() { 4 | return ( 5 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/api/docs/page.tsx: -------------------------------------------------------------------------------- 1 | import SwaggerUI from "swagger-ui-react" 2 | import "swagger-ui-react/swagger-ui.css" 3 | 4 | import { document } from "@/lib/openapi" 5 | 6 | export const dynamic = "force-static" 7 | 8 | export default async function ApiDocsPage() { 9 | const spec = { ...document } 10 | 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /prisma/models/activity.prisma: -------------------------------------------------------------------------------- 1 | model ActivityLog { 2 | id Int @id @default(autoincrement()) 3 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 4 | userId Int @map("user_id") 5 | action String 6 | createdAt DateTime @default(now()) @map("created_at") 7 | 8 | @@map("activity_logs") 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /prisma/models/feedback.prisma: -------------------------------------------------------------------------------- 1 | model Feedback { 2 | id Int @id @default(autoincrement()) 3 | userId Int @map("user_id") 4 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 5 | feedback String 6 | nps Int? 7 | createdAt DateTime @default(now()) @map("created_at") 8 | 9 | @@map("feedbacks") 10 | } 11 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/permissions/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import TableLoading from "@/components/admin/table-loading" 4 | import { PageHeader } from "@/components/page-header" 5 | 6 | export default function LoadingPermissions() { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(root)/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export default function LoadingSettingsPage() { 4 | return ( 5 |
6 | 7 | 8 | 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(tools)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/components/navbar/navbar" 2 | import { SiteFooter } from "@/components/site-footer" 3 | 4 | export default function ToolsLayout({ children }: { children: React.ReactNode }) { 5 | return ( 6 | <> 7 | 8 |
{children}
9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/api/get-pagination.ts: -------------------------------------------------------------------------------- 1 | import { listQuerySchema } from "@/schemas/api" 2 | 3 | export const getPagination = (searchParams: Record) => { 4 | const parsedParams = listQuerySchema.parse(searchParams) 5 | const { page, limit, q: search, sort } = parsedParams 6 | 7 | const offset = (page - 1) * limit 8 | 9 | return { page, limit, offset, search, sort } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/(home)/layout.tsx: -------------------------------------------------------------------------------- 1 | import MaxWidthWrapper from "@/components/max-width-wrapper" 2 | import Navbar from "@/components/navbar/navbar" 3 | 4 | export default function HomeLayout({ children }: { children: React.ReactNode }) { 5 | return ( 6 | <> 7 | 8 |
9 | {children} 10 |
11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/max-width-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | export default function MaxWidthWrapper({ 4 | children, 5 | className, 6 | ...props 7 | }: React.HTMLAttributes) { 8 | return ( 9 |
13 | {children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | export type DataResult = { 2 | success: boolean 3 | errors?: { [P in keyof T]?: string[] } 4 | message?: string 5 | data?: Record 6 | } 7 | 8 | export type NavItem = { 9 | title: string 10 | href: string 11 | type: "parent" | "child" 12 | } 13 | 14 | export interface ITool { 15 | id: number 16 | name: string 17 | href: string 18 | icon: string 19 | isFavorite?: boolean 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/swr/fetcher.ts: -------------------------------------------------------------------------------- 1 | interface SWRError extends Error { 2 | status: number 3 | } 4 | 5 | export async function fetcher( 6 | input: RequestInfo, 7 | init?: RequestInit, 8 | ): Promise { 9 | const res = await fetch(input, init) 10 | 11 | if (!res.ok) { 12 | const error = await res.text() 13 | const err = new Error(error) as SWRError 14 | err.status = res.status 15 | throw err 16 | } 17 | 18 | return res.json() 19 | } 20 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import NotFound404 from "@/components/404" 2 | import MaxWidthWrapper from "@/components/max-width-wrapper" 3 | 4 | export default function NotFoundPage() { 5 | return ( 6 | <> 7 | 8 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/hooks/use-debounce.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | 5 | export function useDebounce(value: T, delay: number): T { 6 | const [debouncedValue, setDebouncedValue] = useState(value) 7 | 8 | useEffect(() => { 9 | const handler = setTimeout(() => { 10 | setDebouncedValue(value) 11 | }, delay) 12 | 13 | return () => { 14 | clearTimeout(handler) 15 | } 16 | }, [value, delay]) 17 | 18 | return debouncedValue 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import MaxWidthWrapper from "@/components/max-width-wrapper" 2 | import Navbar from "@/components/navbar/navbar" 3 | import { SiteFooter } from "@/components/site-footer" 4 | 5 | export default function RootLayout({ children }: { children: React.ReactNode }) { 6 | return ( 7 | <> 8 | 9 |
10 | {children} 11 |
12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/prismadb.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | import { PrismaClient } from "@prisma/client" 3 | import "server-only" 4 | 5 | const globalForPrisma = global as unknown as { prismadb: PrismaClient } 6 | 7 | export const prismadb = 8 | globalForPrisma.prismadb || 9 | new PrismaClient({ 10 | log: env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 11 | }) 12 | 13 | if (env.NODE_ENV !== "production") globalForPrisma.prismadb = prismadb 14 | 15 | export default prismadb 16 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/_components/roles-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import CreateRoleButton from "./create-role-button" 6 | 7 | export default function RolesEmptyStateTable() { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/_components/users-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import CreateUserButton from "./create-user-button" 6 | 7 | export default function UsersEmptyStateTable() { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /public/logo/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/tokens/_components/tokens-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { KeySquare } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import CreateTokenButton from "./create-token-button" 6 | 7 | export default function TokensEmptyStateTable() { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/tools/_components/tools-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutGrid } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import CreateToolButton from "./create-tool-button" 6 | 7 | export default function PermissionsEmptyStateTable() { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/navbar/login-button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Button } from "@/components/ui/button" 4 | 5 | export default function LoginButton() { 6 | return ( 7 |
8 | 11 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/api/tools/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | 3 | import { getCurrentUser } from "@/actions/users/get-current-user" 4 | 5 | import { getUserTools } from "@/data/user" 6 | 7 | export async function GET() { 8 | const currentUser = await getCurrentUser() 9 | 10 | if (currentUser) { 11 | const data = await getUserTools(currentUser.id) 12 | 13 | return NextResponse.json(data, { status: 200 }) 14 | } 15 | 16 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) 17 | } 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.tabSize": 2, 7 | "files.eol": "\n", 8 | "[prisma]": { 9 | "editor.defaultFormatter": "Prisma.prisma" 10 | }, 11 | "workbench.editor.customLabels.patterns": { 12 | "**/app/**/page.tsx": "${dirname} - page.tsx", 13 | "**/app/**/layout.tsx": "${dirname} - layout.tsx" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /prisma/models/tokenPermission.prisma: -------------------------------------------------------------------------------- 1 | model TokenPermission { 2 | tokenId String @map("token_id") 3 | permissionId Int @map("permission_id") 4 | token Token @relation(fields: [tokenId], references: [id], onDelete: Cascade) 5 | permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) 6 | createdBy String @map("created_by") 7 | createdAt DateTime @default(now()) @map("created_at") 8 | 9 | @@id([tokenId, permissionId]) 10 | @@map("token_permissions") 11 | } 12 | -------------------------------------------------------------------------------- /public/logo/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/_components/tool-empty-state.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutGrid } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | export default function ToolEmptyState() { 6 | return ( 7 |
8 | 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/navbar/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react" 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 4 | 5 | export default function UserAvatar() { 6 | const { data: session } = useSession() 7 | 8 | return ( 9 | 10 | 11 | {session?.user?.email?.charAt(0).toUpperCase()} 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/permissions/_components/permissions-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import CreatePermissionButton from "./create-permission-button" 6 | 7 | export default function PermissionsEmptyStateTable() { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/[roleId]/(routes)/users/_components/users-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import AddUserButton from "./add-user-button" 6 | 7 | export default function AddUsersEmptyStateTable({ id }: { id: number }) { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/tools/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { protectPage } from "@/lib/rbac" 2 | 3 | import { PageHeader } from "@/components/page-header" 4 | 5 | import ToolForm from "../_components/tool-form" 6 | 7 | export default async function NewToolPage() { 8 | await protectPage({ permission: "admin:all" }) 9 | 10 | return ( 11 | <> 12 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/[userId]/(routes)/roles/_components/roles-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import AddRoleButton from "./add-role-button" 6 | 7 | export default function AddRolesEmptyStateTable({ id }: { id: string }) { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { protectPage } from "@/lib/rbac" 2 | 3 | import { PageHeader } from "@/components/page-header" 4 | 5 | import CreateUserForm from "./_components/create-user-form" 6 | 7 | export default async function NewUserPage() { 8 | await protectPage({ permission: "admin:all" }) 9 | 10 | return ( 11 | <> 12 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { protectPage } from "@/lib/rbac" 2 | 3 | import { PageHeader } from "@/components/page-header" 4 | 5 | import CreateRoleForm from "./_components/create-role-form" 6 | 7 | export default async function NewRolePage() { 8 | await protectPage({ permission: "admin:all" }) 9 | 10 | return ( 11 | <> 12 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /prisma/models/permission.prisma: -------------------------------------------------------------------------------- 1 | model Permission { 2 | id Int @id @default(autoincrement()) 3 | name String @unique @db.VarChar(45) 4 | key String @unique @db.VarChar(255) 5 | description String @db.VarChar(255) 6 | isActive Boolean @default(false) @map("is_active") 7 | createdAt DateTime @default(now()) @map("created_at") 8 | updatedAt DateTime @default(now()) @updatedAt @map("updated_at") 9 | roles RolePermission[] 10 | tokens TokenPermission[] 11 | 12 | @@map("permissions") 13 | } -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { authConfig } from "@/auth.config" 2 | import NextAuth from "next-auth" 3 | 4 | export const { auth } = NextAuth(authConfig) 5 | 6 | export default auth((req) => { 7 | const isAuthenticated = !!req.auth 8 | 9 | if ( 10 | (req.nextUrl.pathname.startsWith("/auth/login") || 11 | req.nextUrl.pathname.startsWith("/auth/register")) && 12 | isAuthenticated 13 | ) { 14 | return Response.redirect(new URL("/tools", req.url)) 15 | } 16 | }) 17 | 18 | export const config = { 19 | matcher: ["/((?!api|_next/static|_next/image|favicon.ico|auth/error).*)"], 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/_components/card-kpi-loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader } from "@/components/ui/card" 2 | import { Skeleton } from "@/components/ui/skeleton" 3 | 4 | export default function CardKpiLoading() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/[roleId]/(routes)/permissions/_components/permissions-empty-state-table.tsx: -------------------------------------------------------------------------------- 1 | import { FilePlus } from "lucide-react" 2 | 3 | import EmptyState from "@/components/ui/data-tables/empty-state" 4 | 5 | import AddPermissionButton from "./add-permission-button" 6 | 7 | export default function AddPermissionsEmptyStateTable({ id }: { id: number }) { 8 | return ( 9 | } 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/api/get-id-input-or-throw.ts: -------------------------------------------------------------------------------- 1 | import { ApiError } from "./api-error" 2 | 3 | export const getIdInputOrThrow = (idString: string) => { 4 | const id = parseInt(idString) 5 | 6 | if (isNaN(id)) { 7 | throw new ApiError({ 8 | message: "Invalid ID supplied", 9 | code: 400, 10 | }) 11 | } 12 | 13 | return id 14 | } 15 | 16 | export const getIdStringInputOrThrow = (idString: string) => { 17 | if (!idString || typeof idString !== "string") { 18 | throw new ApiError({ 19 | message: "Invalid ID supplied", 20 | code: 400, 21 | }) 22 | } 23 | 24 | return idString 25 | } 26 | -------------------------------------------------------------------------------- /src/components/back-link-button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import React from "react" 4 | 5 | import { ArrowLeft } from "lucide-react" 6 | 7 | import { Button } from "./ui/button" 8 | 9 | export default function BackLinkButton({ link }: { link: string }) { 10 | return ( 11 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/openapi/roles/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi" 2 | 3 | import { createRole } from "./create-role" 4 | import { deleteRole } from "./delete-role" 5 | import { listRoles } from "./list-roles" 6 | import { readRole } from "./read-role" 7 | import { updatePartialRole, updateRole } from "./update-role" 8 | 9 | export const rolesPaths: ZodOpenApiPathsObject = { 10 | "/api/v1/roles": { 11 | get: listRoles, 12 | post: createRole, 13 | }, 14 | "/api/v1/roles/{roleId}": { 15 | get: readRole, 16 | delete: deleteRole, 17 | patch: updatePartialRole, 18 | put: updateRole, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/openapi/users/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi" 2 | 3 | import { createUser } from "./create-user" 4 | import { deleteUser } from "./delete-user" 5 | import { listUsers } from "./list-users" 6 | import { readUser } from "./read-user" 7 | import { updatePartialUser, updateUser } from "./update-user" 8 | 9 | export const usersPaths: ZodOpenApiPathsObject = { 10 | "/api/v1/users": { 11 | get: listUsers, 12 | post: createUser, 13 | }, 14 | "/api/v1/users/{userId}": { 15 | get: readUser, 16 | delete: deleteUser, 17 | patch: updatePartialUser, 18 | put: updateUser, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/permissions/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { protectPage } from "@/lib/rbac" 2 | 3 | import { PageHeader } from "@/components/page-header" 4 | 5 | import CreatePermissionForm from "./_components/create-permission-form" 6 | 7 | export default async function NewPermissionPage() { 8 | await protectPage({ permission: "admin:all" }) 9 | 10 | return ( 11 | <> 12 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/validate-schema-action.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | import { DataResult } from "@/types/types" 4 | 5 | export const validateSchemaAction = ( 6 | schema: z.Schema, 7 | handler: (formData: T) => Promise>, 8 | ) => { 9 | return async (data: T): Promise> => { 10 | const validationResult = schema.safeParse(data) 11 | if (!validationResult.success) { 12 | const errorsValidation = validationResult.error.flatten() 13 | .fieldErrors as DataResult["errors"] 14 | return { success: false, errors: errorsValidation } 15 | } 16 | return handler(data) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(root)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/actions/users/get-current-user" 2 | 3 | import DeleteAccount from "./_components/delete-account-form" 4 | import EmailForm from "./_components/email-form" 5 | import UsernameForm from "./_components/username-form" 6 | 7 | export default async function SettingsPage() { 8 | const currentUser = await getCurrentUser("/auth/login?callbackUrl=/settings") 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(auth)/auth/logout/logout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useRouter, useSearchParams } from "next/navigation" 4 | 5 | import { useEffect } from "react" 6 | 7 | import { signOut } from "next-auth/react" 8 | 9 | export function Logout() { 10 | const router = useRouter() 11 | const searchParams = useSearchParams() 12 | const callbackUrl: string = (searchParams.get("callbackUrl") as string) ?? "/" 13 | 14 | useEffect(() => { 15 | const handleLogout = async () => { 16 | await signOut({ redirect: true, callbackUrl: callbackUrl }) 17 | } 18 | handleLogout() 19 | }, [router, callbackUrl]) 20 | 21 | return null 22 | } 23 | -------------------------------------------------------------------------------- /src/app/(auth)/auth/logout/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { Suspense } from "react" 4 | 5 | import { auth } from "@/auth" 6 | 7 | import { logActivity } from "@/lib/activity" 8 | 9 | import { ActivityType } from "@/schemas/activity-logs" 10 | 11 | import { Logout } from "./logout" 12 | 13 | export default async function LogoutPage() { 14 | const session = await auth() 15 | 16 | if (!session) { 17 | redirect("/auth/login") 18 | } 19 | 20 | await logActivity(session.user.userId, ActivityType.SIGN_OUT) 21 | 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/jwt.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | import jwt from "jsonwebtoken" 3 | 4 | type UserToken = { 5 | email: string 6 | iat: number 7 | exp: number 8 | } 9 | 10 | export function verifyUserToken(token: string) { 11 | try { 12 | const decodedToken = jwt.verify(token, env.JWT_SECRET_KEY) as UserToken 13 | const userEmail = decodedToken.email 14 | return userEmail 15 | } catch { 16 | return false 17 | } 18 | } 19 | 20 | export function generateUserToken(email: string): string { 21 | const token: string = jwt.sign({ email }, env.JWT_SECRET_KEY, { 22 | expiresIn: env.JWT_EXPIRED_IN, 23 | }) 24 | return token 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/openapi/tokens/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi" 2 | 3 | import { createToken } from "./create-token" 4 | import { deleteToken } from "./delete-token" 5 | import { listTokens } from "./list-tokens" 6 | import { readToken } from "./read-token" 7 | import { updatePartialToken, updateToken } from "./update-token" 8 | 9 | export const tokensPaths: ZodOpenApiPathsObject = { 10 | "/api/v1/tokens": { 11 | get: listTokens, 12 | post: createToken, 13 | }, 14 | "/api/v1/tokens/{tokenId}": { 15 | delete: deleteToken, 16 | get: readToken, 17 | patch: updatePartialToken, 18 | put: updateToken, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/actions/users/get-current-user.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { redirect } from "next/navigation" 4 | 5 | import { auth } from "@/auth" 6 | 7 | import prismadb from "@/lib/prismadb" 8 | 9 | export async function getCurrentUser(redirectPage?: string) { 10 | const session = await auth() 11 | 12 | if (redirectPage) { 13 | if (!session) { 14 | redirect(redirectPage) 15 | } 16 | } 17 | 18 | try { 19 | const email = session?.user.email 20 | 21 | if (email) { 22 | const user = await prismadb.user.findUnique({ where: { email } }) 23 | return user 24 | } 25 | return null 26 | } catch { 27 | return null 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SessionProvider } from "next-auth/react" 4 | import { ThemeProvider } from "next-themes" 5 | 6 | import { TooltipProvider } from "./ui/tooltip" 7 | 8 | export default function Providers({ children }: { children: React.ReactNode }) { 9 | return ( 10 | <> 11 | 12 | 18 | {children} 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession, DefaultUser } from "next-auth" 2 | import { DefaultJWT } from "next-auth/jwt" 3 | 4 | declare module "next-auth" { 5 | interface Session { 6 | user: { 7 | userId: number 8 | isActive: boolean 9 | role: "admin" | "user" 10 | } & DefaultSession["user"] 11 | } 12 | 13 | interface User extends Omit { 14 | userId: number 15 | isActive: boolean 16 | role: "admin" | "user" 17 | } 18 | } 19 | 20 | declare module "next-auth/jwt" { 21 | interface JWT extends DefaultJWT { 22 | userId: number 23 | isActive: boolean 24 | role: "admin" | "user" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/roles/_components/roles-table.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb" 2 | 3 | import { DataTable } from "@/components/ui/data-tables/data-table" 4 | 5 | import { columns } from "./columns" 6 | import RolesEmptyStateTable from "./roles-empty-state-table" 7 | 8 | export default async function RolesTable() { 9 | const data = await prismadb.role.findMany({ orderBy: { updatedAt: "desc" } }) 10 | 11 | return ( 12 | } 17 | hiddenColumns={{ ID: false, "Created At": false }} 18 | /> 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner, ToasterProps } from "sonner" 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 22 | ) 23 | } 24 | 25 | export { Toaster } 26 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | // ESLint 4 | "dbaeumer.vscode-eslint", 5 | 6 | // Prettier 7 | "esbenp.prettier-vscode", 8 | 9 | // Tailwind CSS IntelliSense 10 | "bradlc.vscode-tailwindcss", 11 | 12 | // Prisma 13 | "Prisma.prisma", 14 | 15 | // Pretty TypeScript Errors 16 | "YoavBls.pretty-ts-errors", 17 | 18 | // ES7+ React/Redux/React-Native snippets 19 | "dsznajder.es7-react-js-snippets", 20 | 21 | // Error Lens 22 | "usernamehw.errorlens", 23 | 24 | // Console Ninja 25 | "WallabyJs.console-ninja", 26 | 27 | // Code Spell Checker 28 | "streetsidesoftware.code-spell-checker" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/permissions/_components/permissions-table.tsx: -------------------------------------------------------------------------------- 1 | import { getAllPermissions } from "@/data/permission" 2 | 3 | import { DataTable } from "@/components/ui/data-tables/data-table" 4 | 5 | import { columns } from "./columns" 6 | import PermissionsEmptyStateTable from "./permissions-empty-state-table" 7 | 8 | export default async function PermissionsTable() { 9 | const data = await getAllPermissions() 10 | 11 | return ( 12 | } 17 | hiddenColumns={{ ID: false, "Created At": false }} 18 | /> 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/types/token.ts: -------------------------------------------------------------------------------- 1 | export interface Token { 2 | id: string 3 | name: string 4 | partialToken: string 5 | expires: string | null 6 | lastUsed: string 7 | userId: number 8 | createdAt: string 9 | updatedAt: string 10 | type: "inherit" | "custom" 11 | isActive: boolean 12 | permissionIds: number[] | null 13 | permissions: Array<{ 14 | id: number 15 | name: string 16 | }> | null 17 | } 18 | 19 | export interface TokensResponse { 20 | data: Token[] 21 | totalCount: number 22 | totalCountFiltered: number 23 | pageCount: number 24 | } 25 | 26 | export interface TableParams { 27 | page: number 28 | limit: number 29 | search: string 30 | sort: string 31 | } 32 | -------------------------------------------------------------------------------- /src/actions/roles/delete.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | 5 | import prismadb from "@/lib/prismadb" 6 | import { has } from "@/lib/rbac" 7 | 8 | export async function deleteRole(id: number) { 9 | try { 10 | const isAuthorized = await has({ role: "admin" }) 11 | 12 | if (!isAuthorized) { 13 | return { success: false, message: "Unauthorized" } 14 | } 15 | 16 | await prismadb.role.delete({ where: { id } }) 17 | 18 | revalidatePath(`/admin/roles/`) 19 | 20 | return { success: true } 21 | } catch (error) { 22 | console.error("Error deleting role:", error) 23 | return { success: false, message: "Something went wrong" } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/actions/tools/delete.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | 5 | import prismadb from "@/lib/prismadb" 6 | import { has } from "@/lib/rbac" 7 | 8 | export async function deleteTool(id: number) { 9 | try { 10 | const isAuthorized = await has({ role: "admin" }) 11 | 12 | if (!isAuthorized) { 13 | return { success: false, message: "Unauthorized" } 14 | } 15 | 16 | await prismadb.tool.delete({ where: { id } }) 17 | 18 | revalidatePath(`/admin/tools/`) 19 | 20 | return { success: true } 21 | } catch (error) { 22 | console.error("Error deleting tool:", error) 23 | return { success: false, message: "Something went wrong" } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/tools/_components/tools-table.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb" 2 | 3 | import { DataTable } from "@/components/ui/data-tables/data-table" 4 | 5 | import { columns } from "./columns" 6 | import ToolsEmptyStateTable from "./tools-empty-state-table" 7 | 8 | export default async function ToolsTable() { 9 | const data = await prismadb.tool.findMany({ 10 | orderBy: { updatedAt: "desc" }, 11 | }) 12 | 13 | return ( 14 | } 19 | hiddenColumns={{ ID: false, "Created At": false, "Updated At": false }} 20 | /> 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Prisma 2 | DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb" 3 | 4 | # Postgres Database 5 | DATABASE_HOSTNAME=postgres_db 6 | DATABASE_PORT=5432 7 | DATABASE_PASSWORD=postgres 8 | DATABASE_USER=postgres 9 | DATABASE_DB=postgres 10 | 11 | # Next Auth 12 | AUTH_URL=http://localhost:3000 13 | AUTH_URL_INTERNAL=http://localhost:3000 14 | AUTH_SECRET=random_string 15 | 16 | # Auth Google 17 | AUTH_GOOGLE_ID= 18 | AUTH_GOOGLE_SECRET= 19 | 20 | # Auth GitHub 21 | AUTH_GITHUB_ID= 22 | AUTH_GITHUB_SECRET= 23 | 24 | # Email 25 | MAIL_SERVER=smtp.gmail.com 26 | MAIL_PORT=587 27 | MAIL_USE_TLS=true 28 | MAIL_USERNAME= 29 | MAIL_PASSWORD= 30 | 31 | # Jwt 32 | JWT_SECRET_KEY=random_string 33 | JWT_EXPIRED_IN=300 -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/actions/permissions/delete.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | 5 | import prismadb from "@/lib/prismadb" 6 | import { has } from "@/lib/rbac" 7 | 8 | export async function deletePermission(id: number) { 9 | try { 10 | const isAuthorized = await has({ role: "admin" }) 11 | 12 | if (!isAuthorized) { 13 | return { success: false, message: "Unauthorized" } 14 | } 15 | 16 | await prismadb.permission.delete({ where: { id } }) 17 | 18 | revalidatePath(`/admin/permissions/`) 19 | 20 | return { success: true } 21 | } catch (error) { 22 | console.error("Error deleting permission:", error) 23 | return { success: false, message: "Something went wrong" } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/_components/add-tool-button.tsx: -------------------------------------------------------------------------------- 1 | import { Plus } from "lucide-react" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" 5 | 6 | export default function AddToolButton() { 7 | return ( 8 | 9 | 10 | 15 | 16 | 17 |

Add new tool

18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/openapi/permissions/index.ts: -------------------------------------------------------------------------------- 1 | import { ZodOpenApiPathsObject } from "zod-openapi" 2 | 3 | import { createPermission } from "./create-permission" 4 | import { deletePermission } from "./delete-permission" 5 | import { listPermissions } from "./list-permissions" 6 | import { readPermission } from "./read-permission" 7 | import { updatePartialPermission, updatePermission } from "./update-permission" 8 | 9 | export const permissionsPaths: ZodOpenApiPathsObject = { 10 | "/api/v1/permissions": { 11 | get: listPermissions, 12 | post: createPermission, 13 | }, 14 | "/api/v1/permissions/{permissionId}": { 15 | get: readPermission, 16 | delete: deletePermission, 17 | patch: updatePartialPermission, 18 | put: updatePermission, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/app/(tools)/tools/_components/tools-section.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/actions/users/get-current-user" 2 | 3 | import { getUserTools } from "@/data/user" 4 | 5 | import ToolEmptyState from "./tool-empty-state" 6 | import ToolsCards from "./tools-cards" 7 | 8 | export default async function ToolsSection({ 9 | view, 10 | search, 11 | }: { 12 | view: string 13 | search: string 14 | }) { 15 | const currentUser = await getCurrentUser() 16 | 17 | const data = (await getUserTools(currentUser?.id!, search)) || [] 18 | 19 | const favoriteTools = data?.filter((tool) => tool.isFavorite === true) || [] 20 | 21 | if (!data.length) { 22 | return 23 | } 24 | 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | "src/env.ts" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ui/data-tables/server-side/data-table-search.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Search } from "lucide-react" 4 | 5 | import { Input } from "@/components/ui/input" 6 | 7 | interface DataTableSearchProps { 8 | value: string 9 | onChange: (value: string) => void 10 | placeholder?: string 11 | } 12 | 13 | export function DataTableSearch({ 14 | value, 15 | onChange, 16 | placeholder = "Search...", 17 | }: DataTableSearchProps) { 18 | return ( 19 |
20 | 21 | onChange(e.target.value)} 25 | className="pl-8" 26 | /> 27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/hooks/use-copy-to-clipboard.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | 5 | export interface useCopyToClipboardProps { 6 | timeout?: number 7 | } 8 | 9 | export function useCopyToClipboard({ timeout = 2000 }: useCopyToClipboardProps = {}) { 10 | const [isCopied, setIsCopied] = useState(false) 11 | 12 | const copyToClipboard = (value: string) => { 13 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { 14 | return 15 | } 16 | 17 | if (!value) { 18 | return 19 | } 20 | 21 | navigator.clipboard.writeText(value).then(() => { 22 | setIsCopied(true) 23 | 24 | setTimeout(() => { 25 | setIsCopied(false) 26 | }, timeout) 27 | }) 28 | } 29 | 30 | return { isCopied, copyToClipboard } 31 | } 32 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 88, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "quoteProps": "as-needed", 9 | "proseWrap": "always", 10 | "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 11 | "importOrder": [ 12 | "^(next/(.*)$)|^(next$)", 13 | "^(react/(.*)$)|^(react$)", 14 | "", 15 | "^@/types/(.*)$", 16 | "^@/lib/(.*)$", 17 | "^@/schemas/(.*)$", 18 | "^@/actions/(.*)$", 19 | "^@/data/(.*)$", 20 | "^@/components/ui/(.*)$", 21 | "^@/components/(.*)$|^components/(.*)$", 22 | "^@/styles/(.*)$", 23 | "^[./]" 24 | ], 25 | "importOrderSeparation": true, 26 | "importOrderSortSpecifiers": true, 27 | "endOfLine": "lf" 28 | } 29 | -------------------------------------------------------------------------------- /src/schemas/tools.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const toolSchema = z.object({ 4 | id: z.number().optional(), 5 | name: z 6 | .string() 7 | .trim() 8 | .min(1, "Name must contain at least 1 character") 9 | .max(45, "Name must contain at most 45 characters"), 10 | description: z 11 | .string() 12 | .trim() 13 | .min(1, "Description must contain at least 1 character") 14 | .max(255, "Description must contain at most 255 characters"), 15 | href: z 16 | .string() 17 | .trim() 18 | .min(1, "Href must contain at least 1 character") 19 | .max(255, "Href must contain at most 255 characters"), 20 | icon: z 21 | .string() 22 | .trim() 23 | .min(1, "Icon must contain at least 1 character") 24 | .max(255, "Icon must contain at most 255 characters"), 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/health-status-button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Button } from "./ui/button" 4 | 5 | export default function HealthStatusButton() { 6 | return ( 7 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/app/(admin)/admin/users/[userId]/(routes)/logs/page.tsx: -------------------------------------------------------------------------------- 1 | import prismadb from "@/lib/prismadb" 2 | import { protectPage } from "@/lib/rbac" 3 | 4 | import { PageSection } from "@/components/page-header" 5 | 6 | import UserLogsTables from "./user-logs-table" 7 | 8 | type Params = Promise<{ userId: number }> 9 | 10 | export default async function UserLogsPage(props: { params: Params }) { 11 | await protectPage({ permission: "admin:all" }) 12 | 13 | const params = await props.params 14 | const userId = Number(params.userId) 15 | 16 | const data = await prismadb.activityLog.findMany({ 17 | where: { userId }, 18 | orderBy: { createdAt: "desc" }, 19 | }) 20 | 21 | return ( 22 | <> 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 | return ( 7 |