diff --git a/.env.sample b/.env.sample index 0e692b0..a579635 100644 --- a/.env.sample +++ b/.env.sample @@ -22,6 +22,7 @@ BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= IGNORE_HTTPS_ERRORS= IGNORE_URL_SIZE_LIMIT= +ADMINISTRATOR= # AWS S3 Settings SPACES_KEY= diff --git a/components/ModalContent/DeleteUserModal.tsx b/components/ModalContent/DeleteUserModal.tsx new file mode 100644 index 0000000..cf1d3b9 --- /dev/null +++ b/components/ModalContent/DeleteUserModal.tsx @@ -0,0 +1,51 @@ +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useUserStore from "@/store/admin/users"; + +type Props = { + onClose: Function; + userId: number; +}; + +export default function DeleteUserModal({ onClose, userId }: Props) { + const { removeUser } = useUserStore(); + + const deleteUser = async () => { + const load = toast.loading("Deleting..."); + + const response = await removeUser(userId); + + toast.dismiss(load); + + response.ok && toast.success(`User Deleted.`); + + onClose(); + }; + + return ( + +

Delete User

+ +
+ +
+

Are you sure you want to remove this user?

+ +
+ + + Warning: This action is irreversible! + +
+ + +
+
+ ); +} diff --git a/components/ModalContent/NewUserModal.tsx b/components/ModalContent/NewUserModal.tsx new file mode 100644 index 0000000..27e1a15 --- /dev/null +++ b/components/ModalContent/NewUserModal.tsx @@ -0,0 +1,133 @@ +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useUserStore from "@/store/admin/users"; +import TextInput from "../TextInput"; +import { FormEvent, useState } from "react"; + +type Props = { + onClose: Function; +}; + +type FormData = { + name: string; + username?: string; + email?: string; + password: string; +}; + +const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"; + +export default function NewUserModal({ onClose }: Props) { + const { addUser } = useUserStore(); + + const [form, setForm] = useState({ + name: "", + username: "", + email: emailEnabled ? "" : undefined, + password: "", + }); + + const [submitLoader, setSubmitLoader] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + + if (!submitLoader) { + const checkFields = () => { + if (emailEnabled) { + return form.name !== "" && form.email !== "" && form.password !== ""; + } else { + return ( + form.name !== "" && form.username !== "" && form.password !== "" + ); + } + }; + + if (checkFields()) { + if (form.password.length < 8) + return toast.error("Passwords must be at least 8 characters."); + + setSubmitLoader(true); + + const load = toast.loading("Creating Account..."); + + const response = await addUser(form); + + toast.dismiss(load); + setSubmitLoader(false); + + if (response.ok) { + toast.success("User Created!"); + onClose(); + } else { + toast.error(response.data as string); + } + } else { + toast.error("Please fill out all the fields."); + } + } + } + + return ( + +

Create New User

+ +
+ +
+
+
+

Display Name

+ setForm({ ...form, name: e.target.value })} + value={form.name} + /> +
+ + {emailEnabled ? ( +
+

Username

+ setForm({ ...form, username: e.target.value })} + value={form.username} + /> +
+ ) : undefined} + +
+

Email

+ setForm({ ...form, email: e.target.value })} + value={form.email} + /> +
+ +
+

Password

+ setForm({ ...form, password: e.target.value })} + value={form.password} + /> +
+
+ +
+ +
+
+
+ ); +} diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts new file mode 100644 index 0000000..496efcf --- /dev/null +++ b/lib/api/controllers/users/getUsers.ts @@ -0,0 +1,21 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getUsers() { + // Get all users + const users = await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + emailVerified: true, + subscriptions: { + select: { + active: true, + }, + }, + createdAt: true, + }, + }); + + return { response: users, status: 200 }; +} diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index f24738b..84ab2e0 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -1,9 +1,11 @@ import { prisma } from "@/lib/api/db"; import type { NextApiRequest, NextApiResponse } from "next"; import bcrypt from "bcrypt"; +import verifyUser from "../../verifyUser"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; +const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false; interface Data { response: string | object; @@ -20,7 +22,15 @@ export default async function postUser( req: NextApiRequest, res: NextApiResponse ) { - if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") { + let isServerAdmin = false; + + const user = await verifyUser({ req, res }); + if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true; + + if ( + process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" && + !isServerAdmin + ) { return res.status(400).json({ response: "Registration is disabled." }); } @@ -57,13 +67,16 @@ export default async function postUser( }); const checkIfUserExists = await prisma.user.findFirst({ - where: emailEnabled - ? { + where: { + OR: [ + { email: body.email?.toLowerCase().trim(), - } - : { + }, + { username: (body.username as string).toLowerCase().trim(), }, + ], + }, }); if (!checkIfUserExists) { @@ -71,21 +84,63 @@ export default async function postUser( const hashedPassword = bcrypt.hashSync(body.password, saltRounds); - await prisma.user.create({ - data: { - name: body.name, - username: emailEnabled - ? undefined - : (body.username as string).toLowerCase().trim(), - email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, - password: hashedPassword, - }, - }); + // Subscription dates + const currentPeriodStart = new Date(); + const currentPeriodEnd = new Date(); + currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years... - return res.status(201).json({ response: "User successfully created." }); + if (isServerAdmin) { + const user = await prisma.user.create({ + data: { + name: body.name, + username: (body.username as string).toLowerCase().trim(), + email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, + password: hashedPassword, + emailVerified: new Date(), + subscriptions: stripeEnabled + ? { + create: { + stripeSubscriptionId: + "fake_sub_" + Math.round(Math.random() * 10000000000000), + active: true, + currentPeriodStart, + currentPeriodEnd, + }, + } + : undefined, + }, + select: { + id: true, + username: true, + email: true, + emailVerified: true, + subscriptions: { + select: { + active: true, + }, + }, + createdAt: true, + }, + }); + + return res.status(201).json({ response: user }); + } else { + await prisma.user.create({ + data: { + name: body.name, + username: emailEnabled + ? undefined + : (body.username as string).toLowerCase().trim(), + email: emailEnabled ? body.email?.toLowerCase().trim() : undefined, + password: hashedPassword, + }, + }); + + return res.status(201).json({ response: "User successfully created." }); + } } else if (checkIfUserExists) { return res.status(400).json({ - response: `${emailEnabled ? "Email" : "Username"} already exists.`, + response: `Email or Username already exists.`, }); } } diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index 976bd71..9c01dcd 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -10,7 +10,8 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET; export default async function deleteUserById( userId: number, - body: DeleteUserBody + body: DeleteUserBody, + isServerAdmin?: boolean ) { // First, we retrieve the user from the database const user = await prisma.user.findUnique({ @@ -25,13 +26,13 @@ export default async function deleteUserById( } // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration) - if (!keycloakEnabled && !authentikEnabled) { + if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) { const isPasswordValid = bcrypt.compareSync( body.password, user.password as string ); - if (!isPasswordValid) { + if (!isPasswordValid && !isServerAdmin) { return { response: "Invalid credentials.", status: 401, // Unauthorized @@ -43,6 +44,11 @@ export default async function deleteUserById( await prisma .$transaction( async (prisma) => { + // Delete Access Tokens + await prisma.accessToken.deleteMany({ + where: { userId }, + }); + // Delete whitelisted users await prisma.whitelistedUser.deleteMany({ where: { userId }, @@ -87,6 +93,7 @@ export default async function deleteUserById( await prisma.subscription.delete({ where: { userId }, }); + // .catch((err) => console.log(err)); await prisma.usersAndCollections.deleteMany({ where: { diff --git a/pages/admin.tsx b/pages/admin.tsx new file mode 100644 index 0000000..d7b9fda --- /dev/null +++ b/pages/admin.tsx @@ -0,0 +1,174 @@ +import DeleteUserModal from "@/components/ModalContent/DeleteUserModal"; +import NewUserModal from "@/components/ModalContent/NewUserModal"; +import useUserStore from "@/store/admin/users"; +import { User as U } from "@prisma/client"; +import Link from "next/link"; +import { Fragment, useEffect, useState } from "react"; + +interface User extends U { + subscriptions: { + active: boolean; + }; +} + +type UserModal = { + isOpen: boolean; + userId: number | null; +}; + +export default function Admin() { + const { users, setUsers } = useUserStore(); + + const [searchQuery, setSearchQuery] = useState(""); + const [filteredUsers, setFilteredUsers] = useState(); + + const [deleteUserModal, setDeleteUserModal] = useState({ + isOpen: false, + userId: null, + }); + + const [newUserModal, setNewUserModal] = useState(false); + + useEffect(() => { + setUsers(); + }, []); + + return ( +
+
+
+ + + +

+ User Administration +

+
+ +
+
+ + + { + setSearchQuery(e.target.value); + + if (users) { + setFilteredUsers( + users.filter((user) => + JSON.stringify(user) + .toLowerCase() + .includes(e.target.value.toLowerCase()) + ) + ); + } + }} + className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none" + /> +
+ +
setNewUserModal(true)} + className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative" + > + +
+
+
+ +
+ + {filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? ( + UserListing(filteredUsers, deleteUserModal, setDeleteUserModal) + ) : searchQuery !== "" ? ( +

No users found with the given search query.

+ ) : users && users.length > 0 ? ( + UserListing(users, deleteUserModal, setDeleteUserModal) + ) : ( +

No users found.

+ )} + + {newUserModal ? ( + setNewUserModal(false)} /> + ) : null} +
+ ); +} + +const UserListing = ( + users: User[], + deleteUserModal: UserModal, + setDeleteUserModal: Function +) => { + return ( +
+ + + + + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && } + + + + + + {users.map((user, index) => ( + + + + {process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && ( + + )} + {process.env.NEXT_PUBLIC_STRIPE === "true" && ( + + )} + + + + ))} + +
UsernameEmailSubscribedCreated At
{index + 1}{user.username ? user.username : N/A}{user.email} + {user.subscriptions?.active ? ( + JSON.stringify(user.subscriptions?.active) + ) : ( + N/A + )} + {new Date(user.createdAt).toLocaleString()} + +
+ + {deleteUserModal.isOpen && deleteUserModal.userId ? ( + setDeleteUserModal({ isOpen: false, userId: null })} + userId={deleteUserModal.userId} + /> + ) : null} +
+ ); +}; diff --git a/pages/api/v1/users/[id].ts b/pages/api/v1/users/[id].ts index cf75e16..128b426 100644 --- a/pages/api/v1/users/[id].ts +++ b/pages/api/v1/users/[id].ts @@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { return null; } - const userId = token?.id; + const user = await prisma.user.findUnique({ + where: { + id: token?.id, + }, + }); - if (userId !== Number(req.query.id)) + const isServerAdmin = process.env.ADMINISTRATOR === user?.username; + + const userId = isServerAdmin ? Number(req.query.id) : token.id; + + if (userId !== Number(req.query.id) && !isServerAdmin) return res.status(401).json({ response: "Permission denied." }); if (req.method === "GET") { @@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { const updated = await updateUserById(userId, req.body); return res.status(updated.status).json({ response: updated.response }); } else if (req.method === "DELETE") { - const updated = await deleteUserById(userId, req.body); + const updated = await deleteUserById(userId, req.body, isServerAdmin); return res.status(updated.status).json({ response: updated.response }); } } diff --git a/pages/api/v1/users/index.ts b/pages/api/v1/users/index.ts index 3af7bf8..a4768fa 100644 --- a/pages/api/v1/users/index.ts +++ b/pages/api/v1/users/index.ts @@ -1,9 +1,18 @@ import type { NextApiRequest, NextApiResponse } from "next"; import postUser from "@/lib/api/controllers/users/postUser"; +import getUsers from "@/lib/api/controllers/users/getUsers"; +import verifyUser from "@/lib/api/verifyUser"; export default async function users(req: NextApiRequest, res: NextApiResponse) { if (req.method === "POST") { const response = await postUser(req, res); return response; + } else if (req.method === "GET") { + const user = await verifyUser({ req, res }); + if (!user || process.env.ADMINISTRATOR !== user.username) + return res.status(401).json({ response: "Unauthorized..." }); + + const response = await getUsers(); + return res.status(response.status).json({ response: response.response }); } } diff --git a/store/admin/users.ts b/store/admin/users.ts new file mode 100644 index 0000000..6925611 --- /dev/null +++ b/store/admin/users.ts @@ -0,0 +1,66 @@ +import { User as U } from "@prisma/client"; +import { create } from "zustand"; + +interface User extends U { + subscriptions: { + active: boolean; + }; +} + +type ResponseObject = { + ok: boolean; + data: object | string; +}; + +type UserStore = { + users: User[]; + setUsers: () => void; + addUser: (body: Partial) => Promise; + removeUser: (userId: number) => Promise; +}; + +const useUserStore = create((set) => ({ + users: [], + setUsers: async () => { + const response = await fetch("/api/v1/users"); + + const data = await response.json(); + + if (response.ok) set({ users: data.response }); + else if (response.status === 401) window.location.href = "/dashboard"; + }, + addUser: async (body) => { + const response = await fetch("/api/v1/users", { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + users: [...state.users, data.response], + })); + + return { ok: response.ok, data: data.response }; + }, + removeUser: async (userId) => { + const response = await fetch(`/api/v1/users/${userId}`, { + method: "DELETE", + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + users: state.users.filter((user) => user.id !== userId), + })); + + return { ok: response.ok, data: data.response }; + }, +})); + +export default useUserStore; diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 3dccc63..5a94472 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -14,6 +14,7 @@ declare global { ARCHIVE_TAKE_COUNT?: string; IGNORE_UNAUTHORIZED_CA?: string; IGNORE_URL_SIZE_LIMIT?: string; + ADMINISTRATOR?: string; SPACES_KEY?: string; SPACES_SECRET?: string;