diff --git a/apps/web/app/(pages)/admin/page.tsx b/apps/web/app/(pages)/admin/page.tsx new file mode 100644 index 0000000..a02b538 --- /dev/null +++ b/apps/web/app/(pages)/admin/page.tsx @@ -0,0 +1,14 @@ +import AdminDashboard from "@good-dog/components/admin/AdminDashboard"; +import { HydrateClient, trpc } from "@good-dog/trpc/server"; + +export const dynamic = "force-dynamic"; + +export default async function AdminPage() { + void trpc.adminData.prefetch(); + + return ( + + + + ); +} diff --git a/bun.lockb b/bun.lockb index 5b8d3c9..b2798fe 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/components/src/admin/AdminDashboard.tsx b/packages/components/src/admin/AdminDashboard.tsx new file mode 100644 index 0000000..254e9fe --- /dev/null +++ b/packages/components/src/admin/AdminDashboard.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; + +import { trpc } from "@good-dog/trpc/client"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@good-dog/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@good-dog/ui/tabs"; + +import { DataTable } from "./DataTable"; + +export default function AdminDashboard() { + const [activeTab, setActiveTab] = useState("users"); + const [data] = trpc.adminData.useSuspenseQuery(); + + const userData = data.users; + const groupData = data.groups; + const groupInvitesData = data.groupInvites; + + return ( +
+
+

Admin Dashboard

+ + + + Users + + + Groups + + + Invites + + +
+ + + + Users + + Manage user accounts in the system. + + + + + + + + + + + Groups + + Manage user groups and permissions. + + + + + + + + + + + Invites + + Manage pending invitations. + + + + + + + +
+
+
+
+ ); +} diff --git a/packages/components/src/admin/DataTable.tsx b/packages/components/src/admin/DataTable.tsx new file mode 100644 index 0000000..1cb6a52 --- /dev/null +++ b/packages/components/src/admin/DataTable.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React from "react"; + +import type { GetProcedureOutput } from "@good-dog/trpc/utils"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@good-dog/ui/table"; + +export type AdminDataTypes = { + [T in keyof GetProcedureOutput<"adminData">]: GetProcedureOutput<"adminData">[T][number]; +}; + +interface DataColumn { + accessorKey: keyof AdminDataTypes[T] & string; + header: string; + cell?: ( + value: AdminDataTypes[T][keyof AdminDataTypes[T] & string], + ) => React.ReactNode; +} + +const columns: { [T in keyof AdminDataTypes]: DataColumn[] } = { + users: [ + { accessorKey: "firstName", header: "First Name" }, + { accessorKey: "lastName", header: "Last Name" }, + { accessorKey: "email", header: "Email" }, + { accessorKey: "role", header: "Role" }, + { accessorKey: "stageName", header: "Stage Name" }, + { accessorKey: "isSongWriter", header: "Songwriter?" }, + { accessorKey: "isAscapAffiliated", header: "ASCAP Affiliated?" }, + { accessorKey: "isBmiAffiliated", header: "BMI Affiliated?" }, + { accessorKey: "createdAt", header: "Date of Creation" }, + { accessorKey: "updatedAt", header: "Date Last Updated" }, + ], + groups: [ + { accessorKey: "name", header: "Name" }, + { accessorKey: "createdAt", header: "Date of Creation" }, + { accessorKey: "updatedAt", header: "Date Last Updated" }, + ], + groupInvites: [ + { accessorKey: "email", header: "Email" }, + { accessorKey: "firstName", header: "First Name" }, + { accessorKey: "lastName", header: "Last Name" }, + { accessorKey: "stageName", header: "Stage Name" }, + { accessorKey: "role", header: "Role" }, + { accessorKey: "isSongWriter", header: "Songwriter?" }, + { accessorKey: "isAscapAffiliated", header: "ASCAP Affiliated?" }, + { accessorKey: "isBmiAffiliated", header: "BMI Affiliated?" }, + { accessorKey: "createdAt", header: "Date of Creation" }, + ], +}; + +interface DataTableProps { + table: T; + data: AdminDataTypes[T][]; +} + +export function DataTable({ + table, + data, +}: DataTableProps) { + return ( + + + + {columns[table].map((column) => ( + {column.header} + ))} + + + + {data.map((entry, idx) => ( + + {columns[table].map((column) => ( + + {column.cell?.(entry[column.accessorKey]) ?? + String(entry[column.accessorKey])} + + ))} + + ))} + +
+ ); +} diff --git a/packages/components/src/loading/Loading.tsx b/packages/components/src/loading/Loading.tsx new file mode 100644 index 0000000..47fe221 --- /dev/null +++ b/packages/components/src/loading/Loading.tsx @@ -0,0 +1,19 @@ +import { Spinner } from "./Spinner"; + +export default function LoadingPage() { + return ( +
+
+

+ LOADING +

+
+ +
+

+ Please wait while we fetch your content. +

+
+
+ ); +} diff --git a/packages/components/src/loading/Spinner.tsx b/packages/components/src/loading/Spinner.tsx new file mode 100644 index 0000000..f826eec --- /dev/null +++ b/packages/components/src/loading/Spinner.tsx @@ -0,0 +1,10 @@ +export function Spinner({ className = "" }: { className?: string }) { + return ( +
+ Loading... +
+ ); +} diff --git a/packages/db/prisma/migrations/20241124210934_renamed_initiator_id/migration.sql b/packages/db/prisma/migrations/20241124210934_renamed_initiator_id/migration.sql new file mode 100644 index 0000000..e724753 --- /dev/null +++ b/packages/db/prisma/migrations/20241124210934_renamed_initiator_id/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `intitiatorId` on the `GroupInvite` table. All the data in the column will be lost. + - Added the required column `initiatorId` to the `GroupInvite` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "GroupInvite" DROP CONSTRAINT "GroupInvite_intitiatorId_fkey"; + +-- AlterTable +ALTER TABLE "GroupInvite" DROP COLUMN "intitiatorId", +ADD COLUMN "initiatorId" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "GroupInvite" ADD CONSTRAINT "GroupInvite_initiatorId_fkey" FOREIGN KEY ("initiatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 82f33f1..9c49790 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -61,7 +61,7 @@ model Group { model GroupInvite { inviteId String @default(cuid()) @map("id") groupId String - intitiatorId String + initiatorId String email String firstName String lastName String @@ -71,7 +71,7 @@ model GroupInvite { isAscapAffiliated Boolean @default(false) isBmiAffiliated Boolean @default(false) group Group @relation(fields: [groupId], references: [groupId], onDelete: Cascade) - intitiator User @relation(fields: [intitiatorId], references: [userId], onDelete: Cascade) + intitiator User @relation(fields: [initiatorId], references: [userId], onDelete: Cascade) createdAt DateTime @default(now()) @@unique([groupId, email]) diff --git a/packages/trpc/src/internal/init.ts b/packages/trpc/src/internal/init.ts index 2332a1f..70fb011 100644 --- a/packages/trpc/src/internal/init.ts +++ b/packages/trpc/src/internal/init.ts @@ -50,6 +50,7 @@ export const createCallerFactory = t.createCallerFactory; // Procedure builders export const baseProcedureBuilder = t.procedure; + export const authenticatedProcedureBuilder = baseProcedureBuilder.use( async ({ ctx, next }) => { const sessionId = getSessionCookie(); @@ -89,6 +90,15 @@ export const authenticatedProcedureBuilder = baseProcedureBuilder.use( }, ); +export const adminAuthenticatedProcedureBuilder = + authenticatedProcedureBuilder.use(async ({ ctx, next }) => { + if (ctx.session.user.role !== "ADMIN") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + return next({ ctx }); + }); + // This middleware is used to prevent authenticated users from accessing a resource export const notAuthenticatedProcedureBuilder = baseProcedureBuilder.use( async ({ ctx, next }) => { diff --git a/packages/trpc/src/internal/router.ts b/packages/trpc/src/internal/router.ts index 7cc8937..dbf0baf 100644 --- a/packages/trpc/src/internal/router.ts +++ b/packages/trpc/src/internal/router.ts @@ -1,3 +1,4 @@ +import { getAdminViewProcedure } from "../procedures/admin-view"; import { deleteAccountProcedure, signInProcedure, @@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({ user: getUserProcedure, sendForgotPasswordEmail: sendForgotPasswordEmailProcedure, confirmPasswordReset: confirmPasswordResetProcedure, + adminData: getAdminViewProcedure, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/src/procedures/admin-view.ts b/packages/trpc/src/procedures/admin-view.ts new file mode 100644 index 0000000..1f85851 --- /dev/null +++ b/packages/trpc/src/procedures/admin-view.ts @@ -0,0 +1,12 @@ +import { adminAuthenticatedProcedureBuilder } from "../internal/init"; + +export const getAdminViewProcedure = adminAuthenticatedProcedureBuilder.query( + async ({ ctx }) => { + const [users, groups, groupInvites] = await Promise.all([ + ctx.prisma.user.findMany({ omit: { hashedPassword: true } }), + ctx.prisma.group.findMany(), + ctx.prisma.groupInvite.findMany(), + ]); + return { users, groups, groupInvites }; + }, +); diff --git a/packages/trpc/src/procedures/onboarding.ts b/packages/trpc/src/procedures/onboarding.ts index 107ceac..bbd3bdd 100644 --- a/packages/trpc/src/procedures/onboarding.ts +++ b/packages/trpc/src/procedures/onboarding.ts @@ -77,7 +77,7 @@ export const onboardingProcedure = authenticatedProcedureBuilder createMany: { data: input.groupMembers?.map((member) => ({ - intitiatorId: ctx.session.userId, + initiatorId: ctx.session.userId, email: member.email, firstName: member.firstName, lastName: member.lastName, diff --git a/packages/trpc/src/utils.ts b/packages/trpc/src/utils.ts index 2a0e4d3..73b9308 100644 --- a/packages/trpc/src/utils.ts +++ b/packages/trpc/src/utils.ts @@ -1,4 +1,5 @@ import type { TRPCClientErrorLike } from "@trpc/client"; +import type { inferProcedureOutput } from "@trpc/server"; import { z } from "zod"; import type { AppRouter } from "./internal/router"; @@ -13,3 +14,6 @@ export const zPreProcessEmptyString = (schema: I) => return arg; } }, schema); + +export type GetProcedureOutput = + inferProcedureOutput; diff --git a/packages/ui/package.json b/packages/ui/package.json index ca3ac67..920809e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -34,12 +34,12 @@ "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/packages/ui/shad/badge.tsx b/packages/ui/shad/badge.tsx new file mode 100644 index 0000000..a99527d --- /dev/null +++ b/packages/ui/shad/badge.tsx @@ -0,0 +1,37 @@ +import type { VariantProps } from "class-variance-authority"; +import React from "react"; +import { cva } from "class-variance-authority"; + +import { cn } from "@good-dog/ui"; + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/packages/ui/shad/button.tsx b/packages/ui/shad/button.tsx index 4d8fc8d..425a0bb 100644 --- a/packages/ui/shad/button.tsx +++ b/packages/ui/shad/button.tsx @@ -3,10 +3,10 @@ import React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva } from "class-variance-authority"; -import { cn } from "."; +import { cn } from "@good-dog/ui"; const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { diff --git a/packages/ui/shad/card.tsx b/packages/ui/shad/card.tsx new file mode 100644 index 0000000..3469abf --- /dev/null +++ b/packages/ui/shad/card.tsx @@ -0,0 +1,83 @@ +import React from "react"; + +import { cn } from "@good-dog/ui"; + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, +}; diff --git a/packages/ui/shad/table.tsx b/packages/ui/shad/table.tsx new file mode 100644 index 0000000..fdb94ff --- /dev/null +++ b/packages/ui/shad/table.tsx @@ -0,0 +1,120 @@ +import React from "react"; + +import { cn } from "@good-dog/ui"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className, + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className, + )} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/packages/ui/shad/tabs.tsx b/packages/ui/shad/tabs.tsx new file mode 100644 index 0000000..2f1bedb --- /dev/null +++ b/packages/ui/shad/tabs.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@good-dog/ui"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent };