Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin view #24

Merged
merged 16 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading