mirror of
https://github.com/dsoaress/link-free.git
synced 2026-08-17 06:56:04 +01:00
refactor: api routes
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
16
src/components/Textarea/index.tsx
Normal file
16
src/components/Textarea/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/Textarea/styles.ts
Normal file
32
src/components/Textarea/styles.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
21
src/modules/auth/auth.controller.ts
Normal file
21
src/modules/auth/auth.controller.ts
Normal 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()
|
||||||
96
src/modules/auth/auth.service.ts
Normal file
96
src/modules/auth/auth.service.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/modules/data/data.controller.ts
Normal file
19
src/modules/data/data.controller.ts
Normal 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()
|
||||||
29
src/modules/data/data.service.ts
Normal file
29
src/modules/data/data.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/modules/users/users.controller.ts
Normal file
59
src/modules/users/users.controller.ts
Normal 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()
|
||||||
171
src/modules/users/users.service.ts
Normal file
171
src/modules/users/users.service.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
4
src/types/next.d.ts
vendored
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user