From ec0e7e5ec7f17577803718cf017f546c42a8d00f Mon Sep 17 00:00:00 2001 From: diced Date: Sat, 1 Oct 2022 14:04:18 -0700 Subject: [PATCH] feat: edit users (admin-only) --- .../pages/Users/CreateUserModal.tsx | 67 ++++++++ src/components/pages/Users/EditUserModal.tsx | 73 +++++++++ .../pages/{Users.tsx => Users/index.tsx} | 85 ++-------- src/pages/api/user/[id].ts | 146 ++++++++++++++++++ src/pages/api/user/index.ts | 1 - 5 files changed, 302 insertions(+), 70 deletions(-) create mode 100644 src/components/pages/Users/CreateUserModal.tsx create mode 100644 src/components/pages/Users/EditUserModal.tsx rename src/components/pages/{Users.tsx => Users/index.tsx} (59%) create mode 100644 src/pages/api/user/[id].ts diff --git a/src/components/pages/Users/CreateUserModal.tsx b/src/components/pages/Users/CreateUserModal.tsx new file mode 100644 index 0000000..9857c61 --- /dev/null +++ b/src/components/pages/Users/CreateUserModal.tsx @@ -0,0 +1,67 @@ +import { Modal, TextInput, Switch, Group, Button, Title } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { showNotification } from '@mantine/notifications'; +import { DeleteIcon, PlusIcon } from 'components/icons'; +import useFetch from 'hooks/useFetch'; + +export function CreateUserModal({ open, setOpen, updateUsers }) { + const form = useForm({ + initialValues: { + username: '', + password: '', + administrator: false, + }, + }); + + const onSubmit = async values => { + const cleanUsername = values.username.trim(); + const cleanPassword = values.password.trim(); + if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing'); + if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing'); + + const data = { + username: cleanUsername, + password: cleanPassword, + administrator: values.administrator, + }; + + setOpen(false); + const res = await useFetch('/api/auth/create', 'POST', data); + if (res.error) { + showNotification({ + title: 'Failed to create user', + message: res.error, + icon: , + color: 'red', + }); + } else { + showNotification({ + title: 'Created user: ' + cleanUsername, + message: '', + icon: , + color: 'green', + }); + } + + updateUsers(); + }; + + return ( + setOpen(false)} + title={Create User} + > +
onSubmit(v))}> + + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/pages/Users/EditUserModal.tsx b/src/components/pages/Users/EditUserModal.tsx new file mode 100644 index 0000000..96faa08 --- /dev/null +++ b/src/components/pages/Users/EditUserModal.tsx @@ -0,0 +1,73 @@ +import { Modal, TextInput, Switch, Group, Button, Title } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { showNotification } from '@mantine/notifications'; +import { DeleteIcon, PlusIcon } from 'components/icons'; +import useFetch from 'hooks/useFetch'; + +export function EditUserModal({ open, setOpen, updateUsers, user }) { + let form; + + if (user) form = useForm({ + initialValues: { + username: user?.username, + password: '', + administrator: user?.administrator, + }, + }); + + const onSubmit = async values => { + const cleanUsername = values.username.trim(); + const cleanPassword = values.password.trim(); + + const data = { + username: null, + password: null, + administrator: values.administrator, + }; + + if (cleanUsername !== '' && cleanUsername !== user.username) data.username = cleanUsername; + if (cleanPassword !== '') data.password = cleanPassword; + + + setOpen(false); + const res = await useFetch('/api/user/' + user.id, 'PATCH', data); + if (res.error) { + showNotification({ + title: 'Failed to edit user', + message: res.error, + icon: , + color: 'red', + }); + } else { + showNotification({ + title: 'Edited user: ' + cleanUsername, + message: '', + icon: , + color: 'green', + }); + } + + updateUsers(); + }; + + return ( + setOpen(false)} + title={Edit User {user?.username}} + > + {user && ( +
onSubmit(v))}> + + + + + + + + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/pages/Users.tsx b/src/components/pages/Users/index.tsx similarity index 59% rename from src/components/pages/Users.tsx rename to src/components/pages/Users/index.tsx index e080c02..88e8f92 100644 --- a/src/components/pages/Users.tsx +++ b/src/components/pages/Users/index.tsx @@ -1,77 +1,15 @@ -import { ActionIcon, Avatar, Button, Card, Group, Modal, SimpleGrid, Skeleton, Stack, Switch, Text, TextInput, Title } from '@mantine/core'; -import { useForm } from '@mantine/form'; +import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title } from '@mantine/core'; import { useModals } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; -import { CrossIcon, DeleteIcon, PlusIcon } from 'components/icons'; +import { CrossIcon, DeleteIcon, PencilIcon, PlusIcon } from 'components/icons'; import MutedText from 'components/MutedText'; import useFetch from 'hooks/useFetch'; import { userSelector } from 'lib/recoil/user'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; - - -function CreateUserModal({ open, setOpen, updateUsers }) { - const form = useForm({ - initialValues: { - username: '', - password: '', - administrator: false, - }, - }); - - const onSubmit = async values => { - const cleanUsername = values.username.trim(); - const cleanPassword = values.password.trim(); - if (cleanUsername === '') return form.setFieldError('username', 'Username can\'t be nothing'); - if (cleanPassword === '') return form.setFieldError('password', 'Password can\'t be nothing'); - - const data = { - username: cleanUsername, - password: cleanPassword, - administrator: values.administrator, - }; - - setOpen(false); - const res = await useFetch('/api/auth/create', 'POST', data); - if (res.error) { - showNotification({ - title: 'Failed to create user', - message: res.error, - icon: , - color: 'red', - }); - } else { - showNotification({ - title: 'Created user: ' + cleanUsername, - message: '', - icon: , - color: 'green', - }); - } - - updateUsers(); - }; - - return ( - setOpen(false)} - title={Create User} - > -
onSubmit(v))}> - - - - - - - - - -
- ); -} +import { CreateUserModal } from './CreateUserModal'; +import { EditUserModal } from './EditUserModal'; export default function Users() { const user = useRecoilValue(userSelector); @@ -79,7 +17,9 @@ export default function Users() { const modals = useModals(); const [users, setUsers] = useState([]); - const [open, setOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const handleDelete = async (user, delete_images) => { const res = await useFetch('/api/users', 'DELETE', { @@ -142,10 +82,12 @@ export default function Users() { return ( <> - + + + Users - setOpen(true)}> + setCreateOpen(true)}> + {user.administrator ? null : ( + {setEditOpen(true); setSelectedUser(user);}}> + + + )} openDeleteModal(user)}> diff --git a/src/pages/api/user/[id].ts b/src/pages/api/user/[id].ts new file mode 100644 index 0000000..a55b2f1 --- /dev/null +++ b/src/pages/api/user/[id].ts @@ -0,0 +1,146 @@ +import prisma from 'lib/prisma'; +import { hashPassword } from 'lib/util'; +import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline'; +import Logger from 'lib/logger'; + +async function handler(req: NextApiReq, res: NextApiRes) { + const user = await req.user(); + if (!user) return res.forbid('not logged in'); + + if (!user.administrator) return res.forbid('not an administrator'); + + const { id } = req.query as { id: string }; + + const target = await prisma.user.findFirst({ + where: { + id: Number(id), + }, + }); + + if (!target) return res.error('user not found'); + + if (req.method === 'GET') { + delete target.password; + + return res.json(target); + } else if (req.method === 'DELETE') { + const newTarget = await prisma.user.delete({ + where: { id: target.id }, + }); + + delete newTarget.password; + + return res.json(newTarget); + } else if (req.method === 'PATCH') { + if (target.administrator) return res.forbid('cannot modify administrator'); + + if (req.body.password) { + const hashed = await hashPassword(req.body.password); + await prisma.user.update({ + where: { id: target.id }, + data: { password: hashed }, + }); + } + + if (req.body.administrator) { + await prisma.user.update({ + where: { id: target.id }, + data: { administrator: req.body.administrator }, + }); + } + + if (req.body.username) { + const existing = await prisma.user.findFirst({ + where: { + username: req.body.username, + }, + }); + if (existing && user.username !== req.body.username) { + return res.forbid('username is already taken'); + } + await prisma.user.update({ + where: { id: target.id }, + data: { username: req.body.username }, + }); + } + + if (req.body.avatar) await prisma.user.update({ + where: { id: target.id }, + data: { avatar: req.body.avatar }, + }); + + if (req.body.embedTitle) await prisma.user.update({ + where: { id: target.id }, + data: { embedTitle: req.body.embedTitle }, + }); + + if (req.body.embedColor) await prisma.user.update({ + where: { id: target.id }, + data: { embedColor: req.body.embedColor }, + }); + + if (req.body.embedSiteName) await prisma.user.update({ + where: { id: target.id }, + data: { embedSiteName: req.body.embedSiteName }, + }); + + if (req.body.systemTheme) await prisma.user.update({ + where: { id: target.id }, + data: { systemTheme: req.body.systemTheme }, + }); + + if (req.body.domains) { + if (!req.body.domains) await prisma.user.update({ + where: { id: target.id }, + data: { domains: [] }, + }); + + const invalidDomains = []; + const domains = []; + + for (const domain of req.body.domains) { + try { + const url = new URL(domain); + domains.push(url.origin); + } catch (e) { + invalidDomains.push({ domain, reason: e.message }); + } + } + + if (invalidDomains.length) return res.forbid('invalid domains', { invalidDomains }); + + await prisma.user.update({ + where: { id: target.id }, + data: { domains }, + }); + + return res.json({ domains }); + } + + const newUser = await prisma.user.findFirst({ + where: { + id: target.id, + }, + select: { + administrator: true, + embedColor: true, + embedTitle: true, + embedSiteName: true, + id: true, + images: false, + password: false, + systemTheme: true, + token: true, + username: true, + domains: true, + avatar: true, + }, + }); + + Logger.get('user').info(`User ${user.username} (${user.id}) updated ${target.username} (${newUser.username}) (${newUser.id})`); + + return res.json(newUser); + } +} + +export default withZipline(handler); \ No newline at end of file diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 9ee2179..3b290aa 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -2,7 +2,6 @@ import prisma from 'lib/prisma'; import { hashPassword } from 'lib/util'; import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline'; import Logger from 'lib/logger'; -import pkg from '../../../../package.json'; async function handler(req: NextApiReq, res: NextApiRes) { const user = await req.user();