refactor: api routes

This commit is contained in:
Daniel Soares
2021-12-24 11:48:37 -03:00
parent 19aa8d2311
commit 165c26af3f
42 changed files with 622 additions and 506 deletions

View File

@@ -30,9 +30,7 @@
"next-connect": "0.11.0", "next-connect": "0.11.0",
"nookies": "2.5.2", "nookies": "2.5.2",
"polished": "4.1.3", "polished": "4.1.3",
"rc-slider": "9.7.5",
"react": "17.0.2", "react": "17.0.2",
"react-collapsible": "2.8.4",
"react-color": "2.19.3", "react-color": "2.19.3",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-hot-toast": "^2.1.1", "react-hot-toast": "^2.1.1",

View File

@@ -87,6 +87,7 @@ async function main() {
await prisma.user.create({ await prisma.user.create({
data: { data: {
id: 1,
username: USERNAME, username: USERNAME,
password: hashSync(PASSWORD, 10), password: hashSync(PASSWORD, 10),
lang: 'en', lang: 'en',

View File

@@ -1,33 +0,0 @@
import { compare } from 'bcryptjs'
import { prisma } from 'services/prisma'
import { createSession } from 'utils/createSession'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function auth(req: NextApiRequest, res: NextApiResponse) {
const { username, password } = req.body
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
const user = await prisma.user.findUnique({
where: { username }
})
if (!user) {
throw new ExceptionError('Credentials are invalid', 401)
}
const isValidPassword = await compare(password, user.password)
if (!isValidPassword) {
throw new ExceptionError('Credentials are invalid', 401)
}
const { accessToken, refreshToken } = await createSession(user)
res.status(200).json({ accessToken, refreshToken })
}

View File

@@ -1,47 +0,0 @@
import { prisma } from 'services/prisma'
import { createSession } from 'utils/createSession'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function refreshToken(req: NextApiRequest, res: NextApiResponse) {
const { refreshToken } = req.body
if (!refreshToken) {
throw new ExceptionError('Refresh Token required')
}
const session = await prisma.session.findUnique({
where: { id: refreshToken }
})
if (!session) {
throw new ExceptionError('Refresh Token are invalid', 401)
}
const isSessionExpired = new Date(session.expiresAt) < new Date()
if (isSessionExpired) {
await prisma.session.delete({
where: { id: refreshToken }
})
throw new ExceptionError('Refresh Token are expired', 401)
}
const user = await prisma.user.findUnique({
where: { id: session.userId }
})
if (!user) {
throw new ExceptionError('User not found', 401)
}
const tokens = await createSession(user)
await prisma.session.delete({
where: { id: refreshToken }
})
res.status(200).json({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken })
}

View File

@@ -1,16 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function getData(_req: NextApiRequest, res: NextApiResponse) {
const response = await prisma.data.findUnique({
where: { id: 1 }
})
if (!response) {
throw new ExceptionError('No data found')
}
res.status(200).json(response?.data)
}

View File

@@ -1,23 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function updateData(req: NextApiRequest, res: NextApiResponse) {
const { data } = req.body
if (!data) {
throw new ExceptionError('No data provided')
}
try {
const response = await prisma.data.update({
where: { id: 1 },
data: { data: JSON.stringify(req.body.data) }
})
res.status(200).json(response)
} catch (err: any) {
throw new ExceptionError(err)
}
}

View File

@@ -1,13 +0,0 @@
export { auth } from './auth'
export { refreshToken } from './auth/refreshToken'
export { getData } from './data/getData'
export { updateData } from './data/updateData'
export { checkUsername } from './users/checkUsername'
export { createUser } from './users/createUser'
export { deleteUser } from './users/deleteUser'
export { getUserById } from './users/getUserById'
export { getUsers } from './users/getUsers'
export { me } from './users/me'
export { updateUser } from './users/updateUser'

View File

@@ -1,13 +0,0 @@
import { prisma } from 'services/prisma'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function checkUsername(req: NextApiRequest, res: NextApiResponse) {
const { username } = req.body
const isTaken = await prisma.user.findUnique({
where: { username: username.toLowerCase().trim() }
})
res.status(200).json({ isTaken: !!isTaken })
}

View File

@@ -1,50 +0,0 @@
import { hashSync } from 'bcryptjs'
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function createUser(req: NextApiRequest, res: NextApiResponse) {
const { username, password, role } = req.body
const normalizedUsername = username.toLowerCase().trim()
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
if (req.userRole !== 'ADMIN') {
throw new ExceptionError('Only admins can create users')
}
const usernameExists = await prisma.user.findUnique({
where: { username: normalizedUsername }
})
if (usernameExists) {
throw new ExceptionError('Username already exists')
}
try {
const response = await prisma.user.create({
data: {
username: normalizedUsername,
password: hashSync(password, 10),
role: role ? role : 'EDITOR'
},
select: {
id: true,
username: true,
role: true
}
})
res.status(200).json(response)
} catch (err: any) {
throw new ExceptionError(err)
}
}

View File

@@ -1,26 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function deleteUser(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.params
if (id === '1') {
throw new ExceptionError('Cannot delete admin', 403)
}
if (req.userRole !== 'ADMIN') {
throw new ExceptionError('Only admins can delete other users')
}
try {
await prisma.user.delete({
where: { id: +id }
})
res.status(204).end()
} catch (err: any) {
throw new ExceptionError(err)
}
}

View File

@@ -1,23 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function getUserById(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.params
const response = await prisma.user.findUnique({
where: { id: +id },
select: {
id: true,
username: true,
role: true
}
})
if (!response) {
throw new ExceptionError('No data found', 404)
}
res.status(200).json(response)
}

View File

@@ -1,20 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function getUsers(_req: NextApiRequest, res: NextApiResponse) {
const response = await prisma.user.findMany({
select: {
id: true,
username: true,
role: true
}
})
if (!response) {
throw new ExceptionError('No data found')
}
res.status(200).json(response)
}

View File

@@ -1,23 +0,0 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function me(req: NextApiRequest, res: NextApiResponse) {
const { userId } = req
const response = await prisma.user.findUnique({
where: { id: +userId },
select: {
id: true,
username: true,
lang: true,
role: true
}
})
if (!response) {
throw new ExceptionError('User not found', 404)
}
res.status(200).json(response)
}

View File

@@ -1,67 +0,0 @@
import { compare, hashSync } from 'bcryptjs'
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next'
export async function updateUser(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.params
const { oldPassword, password, role } = req.body
const user = await prisma.user.findUnique({
where: { id: +id }
})
let updatedUser = {}
if (!user) {
throw new ExceptionError('No user found', 404)
}
// if (req.userRole !== 'ADMIN' || req.userId !== id) {
// throw new ExceptionError('Only admins can update other users')
// }
if (oldPassword && password) {
const isValidPassword = await compare(oldPassword, user.password)
if (!isValidPassword) {
throw new ExceptionError('Password does not match')
}
updatedUser = { password: hashSync(password, 10) }
}
if (role) {
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
if (user.role === 'ADMIN' && role === 'EDITOR') {
throw new ExceptionError('Cannot downgrade admin role', 403)
}
if (role !== 'EDITOR' && user.role === 'EDITOR') {
throw new ExceptionError('Cannot change role', 403)
}
updatedUser = { ...updatedUser, role }
}
try {
const response = await prisma.user.update({
where: { id: +id },
data: updatedUser,
select: {
id: true,
username: true,
lang: true,
role: true
}
})
res.status(200).json(response)
} catch (err: any) {
throw new ExceptionError(err)
}
}

View File

@@ -10,27 +10,28 @@ type ButtonProps = {
} }
export const Button = styled(BaseButton)<ButtonProps>` export const Button = styled(BaseButton)<ButtonProps>`
background: #0284c7; background: #000;
border-color: #0284c7; border-color: #000;
border-width: 1px; border-width: 1px;
border-radius: 4px;
&:hover { &:hover {
transform: translateY(0); transform: translateY(0);
background: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.1, '#0284c7'))}; background: ${({ outlined }) => (outlined ? '#000' : lighten(0.3, '#000'))};
border-color: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.1, '#0284c7'))}; border-color: ${({ outlined }) => (outlined ? '#000' : lighten(0.3, '#000'))};
} }
&:active { &:active {
background: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.2, '#0284c7'))}; background: ${({ outlined }) => (outlined ? '#000' : lighten(0.4, '#000'))};
border-color: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.2, '#0284c7'))}; border-color: ${({ outlined }) => (outlined ? '#000' : lighten(0.4, '#000'))};
} }
${({ outlined }) => ${({ outlined }) =>
outlined && outlined &&
` `
color: #0284c7; color: #000;
background: transparent; background: transparent;
border-color: #0284c7; border-color: #000;
&:hover { &:hover {
color: #f8fafc; color: #f8fafc;

View File

@@ -1,11 +1,12 @@
import { Textarea } from 'components/Textarea'
import { useData } from 'hooks/useData' import { useData } from 'hooks/useData'
export function DescriptionInput() { export function DescriptionInput() {
const { data, setData } = useData() const { data, setData } = useData()
return ( return (
<textarea <Textarea
style={{ height: '200px !important', width: '100% !important' }} label="Description"
value={data.settings.description} value={data.settings.description}
onChange={e => onChange={e =>
setData({ setData({

View File

@@ -1,30 +1,29 @@
import { useData } from 'hooks/useData' import { FaFont } from 'react-icons/fa'
import type { Fonts } from 'types/Fonts' import { useData } from 'hooks/useData'
import { fonts } from 'constants/fonts'
import { Select } from 'components/Select'
import type { Fonts as FontsType } from 'types/Fonts'
export function FontsSelect() { export function FontsSelect() {
const { data, setData } = useData() const { data, setData } = useData()
return ( return (
<select <Select
label="Font select"
icon={FaFont}
value={data.settings.font} value={data.settings.font}
onChange={e => onChange={e =>
setData({ setData({
...data, ...data,
settings: { settings: {
...data.settings, ...data.settings,
font: e.target.value as Fonts font: e.target.value as FontsType
} }
}) })
} }
> options={Object.entries(fonts).map(([value, { name: label }]) => ({ label, value }))}
<option value="inconsolata">Inconsolata</option> />
<option value="oswald">Oswald</option>
<option value="poppins">Poppins</option>
<option value="quicksand">Quicksand</option>
<option value="roboto">Roboto</option>
<option value="robotoSlab">Roboto Slab</option>
<option value="ubuntu">Ubuntu</option>
</select>
) )
} }

View File

@@ -1,14 +1,14 @@
import 'rc-slider/assets/index.css' import { useState } from 'react'
import Slider from 'rc-slider'
import { useData } from 'hooks/useData' import { useData } from 'hooks/useData'
import { DescriptionInput } from 'components/Dash/DescriptionInput' import { DescriptionInput } from 'components/Dash/DescriptionInput'
import { FontsSelect } from 'components/Dash/FontsSelect' import { FontsSelect } from 'components/Dash/FontsSelect'
import { NameInput } from 'components/Dash/NameInput' import { NameInput } from 'components/Dash/NameInput'
import { Input } from 'components/Input'
export function DataSettings() { export function DataSettings() {
const { data, setData } = useData() const { data, setData } = useData()
const [number, setNumber] = useState(0)
return ( return (
<> <>
@@ -18,23 +18,22 @@ export function DataSettings() {
<FontsSelect /> <FontsSelect />
</div> </div>
<div> <Input
<Slider label="Border radius"
value={+data.settings.buttonBorderRadius} type="number"
min={0} min={0}
max={40} step={1}
step={1} value={+data.settings.buttonBorderRadius}
onChange={value => onChange={e =>
setData({ setData({
...data, ...data,
settings: { settings: {
...data.settings, ...data.settings,
buttonBorderRadius: String(value) buttonBorderRadius: e.target.value
} }
}) })
} }
/> />
</div>
</> </>
) )
} }

View File

@@ -1,10 +1,12 @@
import { Input } from 'components/Input'
import { useData } from 'hooks/useData' import { useData } from 'hooks/useData'
export function NameInput() { export function NameInput() {
const { data, setData } = useData() const { data, setData } = useData()
return ( return (
<input <Input
label="Name"
value={data.settings.name} value={data.settings.name}
onChange={e => onChange={e =>
setData({ setData({

View File

@@ -1,9 +1,14 @@
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { toast } from 'react-hot-toast'
import { useState } from 'react'
import { useData } from 'hooks/useData' import { useData } from 'hooks/useData'
import { api } from 'services/api' import { api } from 'services/api'
import { removeLocalStorage } from 'utils/localStorage' import { removeLocalStorage } from 'utils/localStorage'
import { Button } from 'components/Button' import { useI18n } from 'hooks/useI18n'
import { Button } from '../Button'
import { AlertModal } from '../AlertModal'
import { ButtonsGroup, Wrapper } from './styles' import { ButtonsGroup, Wrapper } from './styles'
@@ -23,17 +28,25 @@ export function SaveChangesAlert({
}: SaveChangesAlertProps) { }: SaveChangesAlertProps) {
const { push } = useRouter() const { push } = useRouter()
const { data, setData } = useData() const { data, setData } = useData()
const { t } = useI18n()
const [isDeleteUnsavedDataModalOpen, setIsDeleteUnsavedDataModalOpen] = useState(false)
const handleSave = async () => { const handleSave = () => {
try { const saving = toast.loading(t.common.saving)
await api.put('data', { data })
removeLocalStorage('data') api
setHasUnsavedChanges(false) .put('data', { data })
setData(data) .then(() => {
push('/') removeLocalStorage('data')
} catch (error) { setHasUnsavedChanges(false)
console.error(error) toast.success(t.userSection.editUserModal.success, { id: saving })
} setData(data)
push('/')
})
.catch(err => {
console.error(err)
toast.error(t.userSection.editUserModal.error, { id: saving })
})
} }
const handleCancel = () => { const handleCancel = () => {
@@ -43,16 +56,31 @@ export function SaveChangesAlert({
} }
return hasUnsavedChanges ? ( return hasUnsavedChanges ? (
<Wrapper> <>
You have unsaved changes. <Wrapper>
<ButtonsGroup> You have unsaved changes.
<Button size="small" backgroundColor="transparent" onClick={() => handleCancel()}> <ButtonsGroup>
Cancel <Button
</Button> size="small"
<Button size="small" onClick={() => handleSave()}> outlined
Save danger
</Button> onClick={() => setIsDeleteUnsavedDataModalOpen(true)}
</ButtonsGroup> >
</Wrapper> Cancel
</Button>
<Button size="small" onClick={() => handleSave()}>
Save
</Button>
</ButtonsGroup>
</Wrapper>
<AlertModal
title={t.userSection.deleteUserModal.title}
description={'teste'}
isOpen={isDeleteUnsavedDataModalOpen}
onClose={() => setIsDeleteUnsavedDataModalOpen(false)}
callback={() => handleCancel()}
/>
</>
) : null ) : null
} }

View File

@@ -11,6 +11,8 @@ export const Wrapper = styled.div`
padding: 1rem; padding: 1rem;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1; line-height: 1;
background-color: #ddd;
z-index: 10;
` `
export const ButtonsGroup = styled.div` export const ButtonsGroup = styled.div`

View File

@@ -15,7 +15,7 @@ import { InputsGroup, Wrapper } from './styles'
import type { User } from 'types/User' import type { User } from 'types/User'
import type { Role } from '@prisma/client' import type { Role } from '@prisma/client'
import type { FormEvent, Dispatch, SetStateAction } from 'react' import type { FormEvent } from 'react'
type EditUserModalProps = { type EditUserModalProps = {
isOpen: boolean isOpen: boolean
@@ -138,7 +138,7 @@ export function EditUserModal({ isOpen, onClose, user }: EditUserModalProps) {
autoComplete="new-password" autoComplete="new-password"
errorMessage={errors.password} errorMessage={errors.password}
/> />
{user.id !== 1 && ( {user?.id !== 1 && (
<Can roles={['ADMIN']}> <Can roles={['ADMIN']}>
<Select <Select
label={t.userSection.common.role} label={t.userSection.common.role}

View File

@@ -1,6 +1,5 @@
import { isEqual } from 'lodash' import { isEqual } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Collapsible from 'react-collapsible'
import { useData } from 'hooks/useData' import { useData } from 'hooks/useData'
import { getLocalStorage, setLocalStorage } from 'utils/localStorage' import { getLocalStorage, setLocalStorage } from 'utils/localStorage'
@@ -8,10 +7,12 @@ import { Home } from 'components/Home'
import { ColorsSettings } from 'components/Dash/ColorsSettings' import { ColorsSettings } from 'components/Dash/ColorsSettings'
import { DataSettings } from 'components/Dash/NameInput/DataSettings' import { DataSettings } from 'components/Dash/NameInput/DataSettings'
import { SaveChangesAlert } from 'components/Dash/SaveChangesAlert' import { SaveChangesAlert } from 'components/Dash/SaveChangesAlert'
import { useAuth } from 'hooks/useAuth'
import { Content, Preview, Wrapper } from './styles' import { Content, Preview, Wrapper } from './styles'
import { UserSettings } from './UserSettings' import { UserSettings } from './UserSettings'
import { SocialLinksSettings } from './SocialLinksSettings' import { SocialLinksSettings } from './SocialLinksSettings'
import { Button } from './Button'
import type { Data } from 'types/Data' import type { Data } from 'types/Data'
@@ -21,6 +22,7 @@ type DashProps = {
export function Dash({ initialData }: DashProps) { export function Dash({ initialData }: DashProps) {
const { data, setData } = useData() const { data, setData } = useData()
const { signOut } = useAuth()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
@@ -28,8 +30,6 @@ export function Dash({ initialData }: DashProps) {
if (isLoading) { if (isLoading) {
const storageData = getLocalStorage<Data>('data') const storageData = getLocalStorage<Data>('data')
if (storageData) setData(storageData) if (storageData) setData(storageData)
else setData(initialData)
setIsLoading(false) setIsLoading(false)
} }
@@ -50,16 +50,14 @@ export function Dash({ initialData }: DashProps) {
initialData={initialData} initialData={initialData}
/> />
<h1>Dash</h1> <h1>Dash</h1>
<Button outlined size="small" onClick={() => signOut()}>
Logout
</Button>
<UserSettings /> <UserSettings />
{/* <SocialLinksSettings /> */} <DataSettings />
{/* <DataSettings /> */} <SocialLinksSettings />
{/* <ColorsSettings /> <ColorsSettings />
<Collapsible trigger="Start here">
<p>This is the collapsible content. It can be any element or React component you like.</p>
<p>It can even be another Collapsible component. Check out the next section!</p>
</Collapsible> */}
</Content> </Content>
<Preview> <Preview>
<Home /> <Home />

View File

@@ -14,6 +14,7 @@ export const Content = styled.div`
padding: 5rem 1rem 1rem; padding: 5rem 1rem 1rem;
font-family: 'Roboto', sans-serif; font-family: 'Roboto', sans-serif;
overflow-y: scroll; overflow-y: scroll;
background: #fff;
` `
export const Preview = styled.div` export const Preview = styled.div`

View File

@@ -11,7 +11,7 @@ export const Home = () => {
const { colors, buttonBorderRadius, font, name, description } = data.settings const { colors, buttonBorderRadius, font, name, description } = data.settings
return ( return (
<Wrapper color={colors.texts} font={fonts[font].name}> <Wrapper color={colors.texts} font={fonts[font].value}>
<Content> <Content>
<Avatar /> <Avatar />
<Name>{name}</Name> <Name>{name}</Name>

View File

@@ -0,0 +1,16 @@
import { TextareaWrapper, Label, Wrapper } from './styles'
import type { TextareaHTMLAttributes } from 'react'
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label?: string
}
export function Textarea({ label, ...rest }: TextareaProps) {
return (
<Wrapper>
{!!label && <Label>{label}</Label>}
<TextareaWrapper {...rest} />
</Wrapper>
)
}

View File

@@ -0,0 +1,32 @@
import styled from 'styled-components'
export const Wrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 226px;
`
export const Label = styled.label`
display: block;
font-size: 0.875rem;
font-wight: 500;
`
export const TextareaWrapper = styled.textarea`
border: 1px solid #ccc;
border-radius: 8px;
padding: 12px;
width: 100%;
height: 200px;
background: transparent;
font-size: 1rem;
resize: none;
transition: all 0.2s ease-in-out;
&:focus {
outline: 0;
box-shadow: 0 0 5px 0 #0284c7;
}
`

View File

@@ -1,14 +1,11 @@
const JWT_EXPIRES_IN = 15000000 // minutes const JWT_EXPIRES_IN = 15000000 // minutes
const SESSION_EXPIRES_IN = 30 // days const SESSION_EXPIRES_IN = 30 // days
const { JWT_SECRET } = process.env
const SESSION_EXPIRES_IN_S = SESSION_EXPIRES_IN * 24 * 60 * 60 const SESSION_EXPIRES_IN_S = SESSION_EXPIRES_IN * 24 * 60 * 60
const SESSION_EXPIRES_IN_MS = SESSION_EXPIRES_IN_S * 1000 const SESSION_EXPIRES_IN_MS = SESSION_EXPIRES_IN_S * 1000
if (!JWT_SECRET && !process.browser) throw new Error('JWT_SECRET is not defined')
export const authConstants = { export const authConstants = {
JWT_SECRET, JWT_SECRET: process.env.JWT_SECRET || 'JWT_SECRET',
JWT_EXPIRES_IN, JWT_EXPIRES_IN,
SESSION_EXPIRES_IN_S, SESSION_EXPIRES_IN_S,
SESSION_EXPIRES_IN_MS SESSION_EXPIRES_IN_MS

View File

@@ -1,30 +1,37 @@
export const fonts = { export const fonts = {
roboto: { roboto: {
name: "'Roboto', sans-serif", name: 'Roboto',
value: "'Roboto', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap'
}, },
robotoSlab: { robotoSlab: {
name: "'Roboto Slab', serif", name: 'Roboto Slab',
value: "'Roboto Slab', serif",
url: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@400;500&display=swap'
}, },
oswald: { oswald: {
name: "'Oswald', sans-serif", name: 'Oswald',
value: "'Oswald', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Oswald:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Oswald:wght@400;500&display=swap'
}, },
poppins: { poppins: {
name: "'Poppins', sans-serif", name: 'Poppins',
value: "'Poppins', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Poppins:wght@400;500&display=swap'
}, },
ubuntu: { ubuntu: {
name: "'Ubuntu', sans-serif", name: 'Ubuntu',
value: "'Ubuntu', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500&display=swap'
}, },
quicksand: { quicksand: {
name: "'Quicksand', sans-serif", name: 'Quicksand',
value: "'Quicksand', sans-serif",
url: 'https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Quicksand:wght@400;500&display=swap'
}, },
inconsolata: { inconsolata: {
name: "'Inconsolata', monospace", name: 'Inconsolata',
value: "'Inconsolata', monospace",
url: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@400;500&display=swap' url: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@400;500&display=swap'
} }
} }

View File

@@ -4,10 +4,11 @@ import { authConstants } from 'constants/auth'
import { ExceptionError } from 'utils/error' import { ExceptionError } from 'utils/error'
import type { NextApiRequest, NextApiResponse } from 'next' import type { NextApiRequest, NextApiResponse } from 'next'
import type { Role } from '@prisma/client'
type Token = { type Token = {
sub: string sub: string
role: string role: Role
} }
export async function authMiddleware(req: NextApiRequest, _res: NextApiResponse, next: () => void) { export async function authMiddleware(req: NextApiRequest, _res: NextApiResponse, next: () => void) {

View File

@@ -0,0 +1,21 @@
import { AuthService } from './auth.service'
import type { NextApiRequest, NextApiResponse } from 'next'
class AuthController {
async auth(req: NextApiRequest, res: NextApiResponse) {
const authService = new AuthService()
const { username, password } = req.body
const result = await authService.auth(username, password)
res.status(200).json(result)
}
async refreshToken(req: NextApiRequest, res: NextApiResponse) {
const authService = new AuthService()
const { refreshToken } = req.body
const result = await authService.refreshToken(refreshToken)
res.status(200).json(result)
}
}
export const authController = new AuthController()

View File

@@ -0,0 +1,96 @@
import jwt from 'jsonwebtoken'
import { compare } from 'bcryptjs'
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import { authConstants } from 'constants/auth'
import type { User } from '@prisma/client'
export class AuthService {
async auth(username: string, password: string) {
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
const user = await prisma.user.findUnique({
where: { username }
})
if (!user) {
throw new ExceptionError('Credentials are invalid', 401)
}
const isValidPassword = await compare(password, user.password)
if (!isValidPassword) {
throw new ExceptionError('Credentials are invalid', 401)
}
const { accessToken, refreshToken } = await this.createSession(user)
return { accessToken, refreshToken }
}
async refreshToken(token: string) {
if (!token) {
throw new ExceptionError('Refresh Token required')
}
const session = await prisma.session.findUnique({
where: { id: token }
})
if (!session) {
throw new ExceptionError('Refresh Token are invalid', 401)
}
const isSessionExpired = new Date(session.expiresAt) < new Date()
if (isSessionExpired) {
await prisma.session.delete({
where: { id: token }
})
throw new ExceptionError('Refresh Token are expired', 401)
}
const user = await prisma.user.findUnique({
where: { id: session.userId }
})
if (!user) {
throw new ExceptionError('User not found', 401)
}
const tokens = await this.createSession(user)
await prisma.session.delete({
where: { id: token }
})
return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }
}
private async createSession(user: User) {
const { SESSION_EXPIRES_IN_MS, JWT_EXPIRES_IN, JWT_SECRET } = authConstants
try {
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_EXPIRES_IN_MS)
}
})
const payload = { role: user.role, sub: user.id }
const accessToken = jwt.sign(payload, JWT_SECRET!, {
expiresIn: `${JWT_EXPIRES_IN}m`
})
return { accessToken, refreshToken: session.id }
} catch (err: any) {
throw new ExceptionError(err)
}
}
}

View File

@@ -0,0 +1,19 @@
import { DataService } from './data.service'
import type { NextApiRequest, NextApiResponse } from 'next'
class DataController {
async getData(_req: NextApiRequest, res: NextApiResponse) {
const dataService = new DataService()
const result = await dataService.getData()
res.status(200).json(result)
}
async updateData(req: NextApiRequest, res: NextApiResponse) {
const dataService = new DataService()
const result = await dataService.updateData(req.body.data)
res.status(200).json(result)
}
}
export const dataController = new DataController()

View File

@@ -0,0 +1,29 @@
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
export class DataService {
async getData() {
const response = await prisma.data.findUnique({
where: { id: 1 }
})
if (!response) {
throw new ExceptionError('No data found', 404)
}
return response.data
}
async updateData(data = {}) {
const response = await prisma.data.update({
where: { id: 1 },
data: { data: JSON.stringify(data) }
})
if (!response) {
throw new ExceptionError('No data found', 404)
}
return response.data
}
}

View File

@@ -0,0 +1,59 @@
import { UsersService } from './users.service'
import type { NextApiRequest, NextApiResponse } from 'next'
class UsersController {
async createUser(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { username, password, role } = req.body
const { userRole } = req
const result = await usersService.createUser(username, password, role, userRole)
res.status(200).json(result)
}
async getUser(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { id: userId } = req.params
const result = await usersService.getUser(+userId)
res.status(200).json(result)
}
async getUsers(_req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const result = await usersService.getUsers()
res.status(200).json(result)
}
async getMe(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { userId } = req
const result = await usersService.getUser(+userId)
res.status(200).json(result)
}
async checkUserExists(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { username } = req.body
const result = await usersService.checkUserExists(username)
res.status(200).json({ isTaken: result })
}
async updateUser(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { id: userId } = req.params
const { oldPassword, password, role } = req.body
const { userRole } = req
const result = await usersService.updateUser(+userId, userRole, oldPassword, password, role)
res.status(200).json(result)
}
async deleteUser(req: NextApiRequest, res: NextApiResponse) {
const usersService = new UsersService()
const { id: userId } = req.params
const { userRole } = req
await usersService.deleteUser(+userId, userRole)
res.status(204).end()
}
}
export const usersController = new UsersController()

View File

@@ -0,0 +1,171 @@
import { compare, hashSync } from 'bcryptjs'
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { Role } from '@prisma/client'
export class UsersService {
async createUser(username: string, password: string, role: Role = 'EDITOR', userRole: Role) {
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
if (userRole !== 'ADMIN') {
throw new ExceptionError('Only admins can create users', 403)
}
const usernameExists = await this.checkUserExists(username)
if (usernameExists) {
throw new ExceptionError('Username already exists')
}
const response = await prisma.user.create({
data: {
username: username,
password: hashSync(password, 10),
role: role ? role : 'EDITOR'
},
select: {
id: true,
username: true,
role: true
}
})
if (!response) {
throw new ExceptionError('User not created')
}
return response
}
async getUser(userId: number, withPassword = false) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
password: withPassword,
lang: true,
role: true
}
})
if (!user) {
throw new ExceptionError('User not found', 404)
}
return user
}
async getUsers() {
console.log('getUsers')
const response = await prisma.user.findMany({
select: {
id: true,
username: true,
lang: true,
role: true
}
})
console.log(response)
if (!response) {
throw new ExceptionError('No data found', 404)
}
return response
}
async updateUser(
userId: number,
userRole: Role,
oldPassword?: string,
password?: string,
role?: Role
) {
const user = await this.getUser(userId, true)
let updatedUser = {}
if (oldPassword && password) {
const isValidPassword = await compare(oldPassword, user.password)
if (!isValidPassword) {
throw new ExceptionError('Password does not match')
}
updatedUser = { ...updatedUser, password: hashSync(password, 10) }
}
if (role) {
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
if (userRole === 'ADMIN' && role === 'EDITOR') {
throw new ExceptionError('Cannot downgrade admin role', 403)
}
if (role !== 'EDITOR' && userRole === 'EDITOR') {
throw new ExceptionError('Cannot change role', 403)
}
updatedUser = { ...updatedUser, role }
}
if (Object.keys(updatedUser).length === 0) {
throw new ExceptionError('No data to update')
}
const response = await prisma.user.update({
where: { id: userId },
data: updatedUser,
select: {
id: true,
username: true,
lang: true,
role: true
}
})
if (!response) {
throw new ExceptionError('User not updated')
}
return response
}
async deleteUser(userId: number, userRole: Role) {
if (userId === 1) {
throw new ExceptionError('Cannot delete default admin', 403)
}
if (userRole !== 'ADMIN') {
throw new ExceptionError('Only admins can delete other users', 403)
}
try {
await prisma.user.delete({
where: { id: userId }
})
} catch (err: any) {
throw new ExceptionError(err)
}
}
async checkUserExists(username: string) {
const user = await prisma.user.findUnique({
where: { username }
})
return !!user
}
}

View File

@@ -1,4 +1,5 @@
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'
import Modal from 'react-modal'
import { Layout } from 'components/Layout' import { Layout } from 'components/Layout'
import { AuthProvider } from 'contexts/AuthContext' import { AuthProvider } from 'contexts/AuthContext'
@@ -19,7 +20,8 @@ export default function App({ Component, pageProps }: AppProps) {
</I18nProvider> </I18nProvider>
</AuthProvider> </AuthProvider>
</DataProvider> </DataProvider>
<Toaster toastOptions={{ duration: 5000, position: 'top-left' }} /> <Toaster toastOptions={{ duration: 5000, position: 'top-right' }} />
{Modal.setAppElement('#__next')}
</> </>
) )
} }

View File

@@ -1,6 +1,33 @@
import NextDocument, { Head, Html, Main, NextScript } from 'next/document' import NextDocument, { DocumentContext, Head, Html, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'
export default class Document extends NextDocument { export default class Document extends NextDocument {
static async getInitialProps(ctx: DocumentContext) {
const sheet = new ServerStyleSheet()
const originalRenderPage = ctx.renderPage
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
})
const initialProps = await NextDocument.getInitialProps(ctx)
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
}
} finally {
sheet.seal()
}
}
render() { render() {
return ( return (
<Html> <Html>

View File

@@ -1,34 +1,24 @@
import { nc } from 'utils/nc' import { nc } from 'utils/nc'
import { authMiddleware } from 'utils/authMiddleware' import { authMiddleware } from 'middlewares/auth.middleware'
import { import { dataController } from 'modules/data/data.controller'
auth, import { authController } from 'modules/auth/auth.controller'
checkUsername, import { usersController } from 'modules/users/users.controller'
createUser,
deleteUser,
getData,
getUserById,
getUsers,
me,
refreshToken,
updateData,
updateUser
} from 'api'
const handler = nc const handler = nc
.post('/api/auth', auth) .post('/api/auth', authController.auth)
.post('/api/auth/refresh-token', refreshToken) .post('/api/auth/refresh-token', authController.refreshToken)
.use(authMiddleware) .use(authMiddleware)
.get('/api/data', getData) .get('/api/data', dataController.getData)
.put('/api/data', updateData) .put('/api/data', dataController.updateData)
.get('/api/users', getUsers) .get('/api/users', usersController.getUsers)
.get('/api/me', me) .get('/api/me', usersController.getMe)
.get('/api/users/:id', getUserById) .get('/api/users/:id', usersController.getUser)
.post('/api/users/check-username', checkUsername) .post('/api/users/check-username', usersController.checkUserExists)
.post('/api/users', createUser) .post('/api/users', usersController.createUser)
.patch('/api/users/:id', updateUser) .patch('/api/users/:id', usersController.updateUser)
.delete('/api/users/:id', deleteUser) .delete('/api/users/:id', usersController.deleteUser)
export default handler export default handler

View File

@@ -15,7 +15,7 @@ export const Global = createGlobalStyle<GlobalStyleProps>`
} }
body { body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
color: #0f172a; color: #0f172a;
background: ${({ data }) => data.settings.colors.background}; background: ${({ data }) => data.settings.colors.background};
} }

4
src/types/next.d.ts vendored
View File

@@ -1,9 +1,11 @@
import { IncomingMessage } from 'http' import { IncomingMessage } from 'http'
import type { Role } from '@prisma/client'
declare module 'next' { declare module 'next' {
export interface NextApiRequest extends IncomingMessage { export interface NextApiRequest extends IncomingMessage {
userId: string userId: string
userRole: string userRole: Role
params: { params: {
[key: string]: string | string[] [key: string]: string | string[]
} }

View File

@@ -1,29 +0,0 @@
import jwt from 'jsonwebtoken'
import { authConstants } from 'constants/auth'
import { prisma } from 'services/prisma'
import { ExceptionError } from 'utils/error'
import type { User } from '@prisma/client'
export async function createSession(user: User) {
const { SESSION_EXPIRES_IN_MS, JWT_EXPIRES_IN, JWT_SECRET } = authConstants
try {
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + SESSION_EXPIRES_IN_MS)
}
})
const payload = { role: user.role, sub: user.id }
const accessToken = jwt.sign(payload, JWT_SECRET!, {
expiresIn: `${JWT_EXPIRES_IN}m`
})
return { accessToken, refreshToken: session.id }
} catch (err: any) {
throw new ExceptionError(err)
}
}