From 216340708083b8c5426a53796d46c8eec785c72e Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Sun, 19 May 2024 16:32:59 +0200 Subject: [PATCH] feat: admin panel --- .prettierrc | 8 ++ package.json | 1 + .../20240518221152_is_admin/migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 3 +- src/app/admin/page.tsx | 24 ++++++ src/app/globals.css | 4 + src/app/page.tsx | 2 +- src/components/app/BotForm/BotForm.tsx | 46 ++++++++++- .../app/CheckedLoading/CheckedLoading.tsx | 11 +++ src/components/app/NavBar/NavBar.tsx | 7 ++ src/components/app/UserCard/UserCard.tsx | 79 ++++++++++-------- .../app/VerifiedSwitch/VerifiedSwitch.tsx | 43 ++++++++++ src/components/ui/alert.tsx | 61 ++++++++++++++ src/components/ui/switch.tsx | 29 +++++++ src/lib/actions.ts | 80 ++++++++++++++++++- src/lib/auth/index.ts | 4 +- src/lib/zod.ts | 7 +- tailwind.config.ts | 4 + yarn.lock | 21 +++++ 20 files changed, 396 insertions(+), 42 deletions(-) create mode 100644 .prettierrc create mode 100644 prisma/migrations/20240518221152_is_admin/migration.sql create mode 100644 prisma/migrations/20240519122204_optional_invite_link/migration.sql create mode 100644 src/app/admin/page.tsx create mode 100644 src/components/app/CheckedLoading/CheckedLoading.tsx create mode 100644 src/components/app/VerifiedSwitch/VerifiedSwitch.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e536681 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": false, + "printWidth": 800, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "es5", + "semi": false +} \ No newline at end of file diff --git a/package.json b/package.json index cd42874..36be3df 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "@sern/poster": "^1.2.6", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/prisma/migrations/20240518221152_is_admin/migration.sql b/prisma/migrations/20240518221152_is_admin/migration.sql new file mode 100644 index 0000000..7db3f39 --- /dev/null +++ b/prisma/migrations/20240518221152_is_admin/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "isAdmin" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20240519122204_optional_invite_link/migration.sql b/prisma/migrations/20240519122204_optional_invite_link/migration.sql new file mode 100644 index 0000000..cd9795c --- /dev/null +++ b/prisma/migrations/20240519122204_optional_invite_link/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Bot" ALTER COLUMN "inviteLink" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 291befe..7cafb8d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { id String @id @default(cuid()) username String @unique hashed_password String + isAdmin Boolean @default(false) sessions Session[] bots Bot[] } @@ -36,8 +37,8 @@ model Bot { name String description String verified Boolean @default(false) - inviteLink String pfpLink String + inviteLink String? srcLink String? botId String } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..be046ad --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,24 @@ +import UserCard from "@/components/app/UserCard/UserCard"; +import { validateRequest } from "@/lib/auth"; +import prisma from "@/lib/db"; +import { redirect } from "next/navigation"; + +export default async function Page() { + const { user } = await validateRequest() + if (!user) return redirect('/auth/signIn') + if (!user.isAdmin) return ( +

Sorry

+ ) + + const getBots = await prisma.bot.findMany() + return ( + <> +

wow an admin panel

+
+ {getBots.map((bot) => ( + + ))} +
+ + ) +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 5aef2b5..4c43fbf 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,6 +22,8 @@ --accent-foreground: 0 0% 100%; --destructive: 360 80.95% 45.49%; --destructive-foreground: 0 0% 100%; + --warning: 41deg, 86%, 83%; + --warning-foreground: 41deg, 86%, 83%; --ring: 342 59% 54%; --radius: 0.5rem; } @@ -45,6 +47,8 @@ --accent-foreground: 0 0% 100%; --destructive: 360 69.52% 39.02%; --destructive-foreground: 0 0% 100%; + --warning: 41deg, 86%, 83%; + --warning-foreground: 41deg, 86%, 83%; --ring: 342 59% 54%; } } diff --git a/src/app/page.tsx b/src/app/page.tsx index eafbc38..b4ded77 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,7 +19,7 @@ export default function Home() {
- +
diff --git a/src/components/app/BotForm/BotForm.tsx b/src/components/app/BotForm/BotForm.tsx index 7c07046..4cb7aed 100644 --- a/src/components/app/BotForm/BotForm.tsx +++ b/src/components/app/BotForm/BotForm.tsx @@ -3,13 +3,19 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import SubmitButton from "../SubmitButton/SubmitButton"; -import { submitBotData } from "@/lib/actions"; +import { revalidatePathServer, submitBotData, updateBotProfilePicture } from "@/lib/actions"; import { useFormState } from "react-dom"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; export default function BotForm(props: Props) { + const router = useRouter() const [submitData, submitDataAction] = useFormState(submitBotData, null); + const [regenLoading, setRegenLoading] = useState(false) useEffect(() => { if (submitData?.error) { toast.error(submitData.error) @@ -34,15 +40,47 @@ export default function BotForm(props: Props) {
- +
- +
+ {props.botId && ( + + )} + +
+ {props.id && ( +
+ + + Warning + + When resubmitting, the bot will be unverified until the devteam verifies it again.{'\n'} + This doesn't apply when it's just regenerating the profile picture + + +
+ )} ) } diff --git a/src/components/app/CheckedLoading/CheckedLoading.tsx b/src/components/app/CheckedLoading/CheckedLoading.tsx new file mode 100644 index 0000000..3fdc2e3 --- /dev/null +++ b/src/components/app/CheckedLoading/CheckedLoading.tsx @@ -0,0 +1,11 @@ +'use client' + +import { LoaderIcon } from "lucide-react"; + +export default function CheckedLoading(props: Props) { + return props.loading && +} + +interface Props { + loading: boolean; +} \ No newline at end of file diff --git a/src/components/app/NavBar/NavBar.tsx b/src/components/app/NavBar/NavBar.tsx index 89912c3..6f2a05b 100644 --- a/src/components/app/NavBar/NavBar.tsx +++ b/src/components/app/NavBar/NavBar.tsx @@ -63,6 +63,13 @@ export default function Navbar() { My Account + {user.isAdmin && ( + + + Admin panel + + + )} { logoutAction() }}> diff --git a/src/components/app/UserCard/UserCard.tsx b/src/components/app/UserCard/UserCard.tsx index 85c3b05..28297f5 100644 --- a/src/components/app/UserCard/UserCard.tsx +++ b/src/components/app/UserCard/UserCard.tsx @@ -1,38 +1,51 @@ import { Avatar, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" import type { Bot } from "@prisma/client" import Link from "next/link" +import VerifiedSwitch from "../VerifiedSwitch/VerifiedSwitch" -export default function UserCard(props: Bot) { - const yes = Yes - const no = No - return ( - <> -
-
- - - -

{props.name}

-
-
-

{props.description}

-

Verified: {props.verified ? yes : no}

-
-
- {props.srcLink && ( - - - - )} - - - - - - -
-
- - ) -} \ No newline at end of file +export default function UserCard(props: Bot & { isAdminPanel?: boolean }) { + const yes = Yes + const no = No + return ( + <> +
+
+ + + +

+ {props.name} +

+
+
+

{props.description}

+ {!props.isAdminPanel &&

Verified: {props.verified ? yes : no}

} + {props.isAdminPanel &&

Bot ID: {props.botId}

} +
+
+ {props.srcLink && ( + + + + )} + {props.inviteLink && ( + + + + )} + {props.isAdminPanel && } + {!props.isAdminPanel && ( + <> + + + + + )} +
+
+ + ); +} diff --git a/src/components/app/VerifiedSwitch/VerifiedSwitch.tsx b/src/components/app/VerifiedSwitch/VerifiedSwitch.tsx new file mode 100644 index 0000000..30e67ae --- /dev/null +++ b/src/components/app/VerifiedSwitch/VerifiedSwitch.tsx @@ -0,0 +1,43 @@ +'use client' + +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import CheckedLoading from '../CheckedLoading/CheckedLoading' +import { handleBotVerificationSwitch } from '@/lib/actions' +import { useState } from 'react' +import { toast } from 'sonner' + +export default function VerifiedSwitch(props: Props) { + const [loading, setLoading] = useState(false) + const [checked, setChecked] = useState(props.verified) + return ( +
+ + { + setLoading(true) + handleBotVerificationSwitch({ id: props.id, verified: c }).then((ver) => { + setLoading(false) + // sets to the opposite of the current value, like a normal switch + setChecked(!checked) + if (ver.error) { + toast.error(ver.error) + } + if (ver.success) { + toast.success(ver.message) + } + }) + }} + /> + +
+ ) +} + +interface Props { + id: string + verified: boolean +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..f02dd6f --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,61 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-warning/50 text-warning dark:border-warning dark:text-warning", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..bc69cf2 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 11d1c1d..8f1bc47 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -3,8 +3,9 @@ import { redirect } from "next/navigation"; import { validateRequest } from "./auth"; import prisma from "./db"; -import { botSubmitSchema } from "./zod"; +import { botSubmitSchema, botVerificationSwitchSchema } from "./zod"; import poster from '@sern/poster'; +import { revalidatePath } from "next/cache"; export async function submitBotData(prev: any, formData: FormData): Promise { const parsedData = await botSubmitSchema.safeParseAsync(Object.fromEntries(formData.entries())); @@ -58,6 +59,83 @@ export async function submitBotData(prev: any, formData: FormData): Promise { + const parsedData = await botVerificationSwitchSchema.safeParseAsync(data); + const { user } = await validateRequest(); + if (!user) { + return { + success: false, + error: "You must be logged in to perform this action", + }; + } + if (!parsedData.success) { + return { + success: false, + error: `From ${parsedData.error.errors[0].path[0]}: ${parsedData.error.errors[0].message}`, + }; + } + console.log(parsedData.data.id, parsedData.data.verified) + + const botUpdate = await prisma.bot.update({ + where: { + id: parsedData.data.id, + }, + data: { + verified: parsedData.data.verified, + }, + }); + return { + success: true, + message: `Bot verification status for ${botUpdate.name} updated`, + } +} + +export async function updateBotProfilePicture(data: { botId: string }): Promise { + const { user } = await validateRequest() + if (!user) { + return { + success: false, + error: 'You must be logged in to perform this action', + } + } + const bot = await prisma.bot.findFirst({ + where: { + botId: data.botId, + }, + }) + if (!bot) { + return { + success: false, + error: 'Bot not found', + } + } + const botClient = await poster.client(process.env.DSC_TOKEN!) + const userAvatarHash = (await (await botClient('user/get', { user_id: bot.botId })).json()).avatar + if (!userAvatarHash) { + return { + success: false, + error: 'Bot not found on Discord', + } + } + const userAvatar = `https://cdn.discordapp.com/avatars/${bot.botId}/${userAvatarHash}.webp` + await prisma.bot.update({ + where: { + id: bot.id, + }, + data: { + pfpLink: userAvatar, + }, + }) + return { + success: true, + message: 'Profile picture updated', + } +} + +export async function revalidatePathServer(path: string) { + return revalidatePath(path) +} + interface DefaultActionResponse { success: boolean; error?: string; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 1dcdac4..c0556c7 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -18,7 +18,8 @@ export const lucia = new Lucia(adapter, { }, getUserAttributes: (attributes) => { return { - username: attributes.username + username: attributes.username, + isAdmin: attributes.isAdmin }; } }); @@ -68,4 +69,5 @@ declare module "lucia" { interface DatabaseUserAttributes { username: string; + isAdmin: boolean; } \ No newline at end of file diff --git a/src/lib/zod.ts b/src/lib/zod.ts index a88b802..731cb00 100644 --- a/src/lib/zod.ts +++ b/src/lib/zod.ts @@ -6,6 +6,11 @@ export const botSubmitSchema = z.object({ name: z.string(), description: z.string(), botId: z.string(), - inviteLink: z.string(), + inviteLink: z.string().nullable().optional(), srcLink: z.string().nullable().optional() +}) + +export const botVerificationSwitchSchema = z.object({ + id: z.string().min(1), + verified: z.boolean() }) \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 84287e8..bcef2af 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -36,6 +36,10 @@ const config = { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, + warning: { + DEFAULT: "hsl(var(--warning))", + foreground: "hsl(var(--warning-foreground))", + }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", diff --git a/yarn.lock b/yarn.lock index ec8dddc..2335e8c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -682,6 +682,20 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-compose-refs" "1.0.1" +"@radix-ui/react-switch@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.0.3.tgz#6119f16656a9eafb4424c600fdb36efa5ec5837e" + integrity sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-use-previous" "1.0.1" + "@radix-ui/react-use-size" "1.0.1" + "@radix-ui/react-use-callback-ref@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" @@ -712,6 +726,13 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-use-previous@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz#b595c087b07317a4f143696c6a01de43b0d0ec66" + integrity sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-rect@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz#fde50b3bb9fd08f4a1cd204572e5943c244fcec2"