Skip to content

Commit

Permalink
Admin view (#24)
Browse files Browse the repository at this point in the history
* add admin auth

* initial routes to fetch data

* frontend wip

* slight changes to frontend + add route

* fetching data implemented

* front end styling

* fixed one eslint error

* loading page

* route to not found page

* cleanup pagination component

* merge

* trying to fix types

* fix types

* fix tsc

* make fonts bigger

---------

Co-authored-by: owen <owensimpson1127@gmail.com>
  • Loading branch information
sanjana-singhania and owens1127 authored Dec 4, 2024
1 parent b715df0 commit 3b35c1b
Show file tree
Hide file tree
Showing 19 changed files with 562 additions and 6 deletions.
14 changes: 14 additions & 0 deletions apps/web/app/(pages)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HydrateClient>
<AdminDashboard />
</HydrateClient>
);
}
Binary file modified bun.lockb
Binary file not shown.
86 changes: 86 additions & 0 deletions packages/components/src/admin/AdminDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-good-dog-violet pb-10">
<div className="mx-10">
<h1 className="mb-6 text-7xl font-bold text-white">Admin Dashboard</h1>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList>
<TabsTrigger className="text-xl" value="users">
Users
</TabsTrigger>
<TabsTrigger className="text-xl" value="groups">
Groups
</TabsTrigger>
<TabsTrigger className="text-xl" value="invites">
Invites
</TabsTrigger>
</TabsList>
<div className="pt-2">
<TabsContent className="text-3xl" value="users">
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription className="text-xl">
Manage user accounts in the system.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable table="users" data={userData} />
</CardContent>
</Card>
</TabsContent>
<TabsContent className="text-3xl" value="groups">
<Card>
<CardHeader>
<CardTitle>Groups</CardTitle>
<CardDescription className="text-xl">
Manage user groups and permissions.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable table="groups" data={groupData} />
</CardContent>
</Card>
</TabsContent>
<TabsContent className="text-3xl" value="invites">
<Card>
<CardHeader>
<CardTitle>Invites</CardTitle>
<CardDescription className="text-xl">
Manage pending invitations.
</CardDescription>
</CardHeader>
<CardContent>
<DataTable table="groupInvites" data={groupInvitesData} />
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
90 changes: 90 additions & 0 deletions packages/components/src/admin/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends keyof AdminDataTypes> {
accessorKey: keyof AdminDataTypes[T] & string;
header: string;
cell?: (
value: AdminDataTypes[T][keyof AdminDataTypes[T] & string],
) => React.ReactNode;
}

const columns: { [T in keyof AdminDataTypes]: DataColumn<T>[] } = {
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<T extends keyof AdminDataTypes> {
table: T;
data: AdminDataTypes[T][];
}

export function DataTable<T extends keyof AdminDataTypes>({
table,
data,
}: DataTableProps<T>) {
return (
<Table>
<TableHeader className="text-nowrap text-lg">
<TableRow>
{columns[table].map((column) => (
<TableHead key={column.accessorKey}>{column.header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-base">
{data.map((entry, idx) => (
<TableRow key={idx}>
{columns[table].map((column) => (
<TableCell key={column.accessorKey}>
{column.cell?.(entry[column.accessorKey]) ??
String(entry[column.accessorKey])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
19 changes: 19 additions & 0 deletions packages/components/src/loading/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Spinner } from "./Spinner";

export default function LoadingPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-start bg-good-dog-violet pt-44 text-good-dog-pale-yellow">
<div className="space-y-6 text-center">
<h1 className="animate-fade-in text-7xl font-bold tracking-wider">
LOADING
</h1>
<div className="flex justify-center">
<Spinner className="text-good-dog-celadon" />
</div>
<p className="animate-pulse text-lg text-good-dog-orange/70">
Please wait while we fetch your content.
</p>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions packages/components/src/loading/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function Spinner({ className = "" }: { className?: string }) {
return (
<div
className={`inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] ${className}`}
role="status"
>
<span className="sr-only">Loading...</span>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 2 additions & 2 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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])
Expand Down
10 changes: 10 additions & 0 deletions packages/trpc/src/internal/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/trpc/src/internal/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getAdminViewProcedure } from "../procedures/admin-view";
import {
deleteAccountProcedure,
signInProcedure,
Expand Down Expand Up @@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
user: getUserProcedure,
sendForgotPasswordEmail: sendForgotPasswordEmailProcedure,
confirmPasswordReset: confirmPasswordResetProcedure,
adminData: getAdminViewProcedure,
});

export type AppRouter = typeof appRouter;
12 changes: 12 additions & 0 deletions packages/trpc/src/procedures/admin-view.ts
Original file line number Diff line number Diff line change
@@ -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 };
},
);
2 changes: 1 addition & 1 deletion packages/trpc/src/procedures/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/trpc/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,3 +14,6 @@ export const zPreProcessEmptyString = <I extends z.ZodTypeAny>(schema: I) =>
return arg;
}
}, schema);

export type GetProcedureOutput<T extends keyof AppRouter> =
inferProcedureOutput<AppRouter[T]>;
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions packages/ui/shad/badge.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}

export { Badge, badgeVariants };
4 changes: 2 additions & 2 deletions packages/ui/shad/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading

0 comments on commit 3b35c1b

Please sign in to comment.