mirror of
https://github.com/dsoaress/link-free.git
synced 2026-08-16 22:46:04 +01:00
feat: add user management section
This commit is contained in:
@@ -13,13 +13,13 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint --fix",
|
||||
"prepare": "husky install",
|
||||
"clean": "rm -rf node_modules .next data public",
|
||||
"clean": "rm -rf node_modules .next",
|
||||
"type-check": "tsc",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:seed": "prisma db seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "3.6.0",
|
||||
"@prisma/client": "^3.7.0",
|
||||
"axios": "0.24.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"cuid": "2.1.8",
|
||||
@@ -35,7 +35,9 @@
|
||||
"react-collapsible": "2.8.4",
|
||||
"react-color": "2.19.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-hot-toast": "^2.1.1",
|
||||
"react-icons": "4.3.1",
|
||||
"react-modal": "^3.14.4",
|
||||
"sharp": "0.29.3",
|
||||
"styled-components": "5.3.3"
|
||||
},
|
||||
@@ -48,6 +50,7 @@
|
||||
"@types/node": "17.0.0",
|
||||
"@types/react": "17.0.37",
|
||||
"@types/react-color": "3.0.6",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"@types/styled-components": "5.1.18",
|
||||
"babel-plugin-styled-components": "2.0.2",
|
||||
"eslint": "8.5.0",
|
||||
@@ -59,7 +62,7 @@
|
||||
"lint-staged": "12.1.3",
|
||||
"prettier": "2.5.1",
|
||||
"pretty-quick": "3.1.2",
|
||||
"prisma": "3.6.0",
|
||||
"prisma": "^3.7.0",
|
||||
"react-is": "17.0.2",
|
||||
"ts-node": "10.4.0",
|
||||
"typescript": "4.5.4"
|
||||
|
||||
@@ -16,6 +16,7 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String
|
||||
lang String @default("en")
|
||||
role Role
|
||||
}
|
||||
|
||||
|
||||
@@ -27,32 +27,23 @@ async function main() {
|
||||
buttonBorderColor: '#e11d48'
|
||||
}
|
||||
},
|
||||
socialLinks: [
|
||||
{
|
||||
label: 'Email',
|
||||
href: '1'
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: '2'
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
href: '3'
|
||||
},
|
||||
{
|
||||
label: 'Dev',
|
||||
href: '4'
|
||||
},
|
||||
{
|
||||
label: 'Instagram',
|
||||
href: '5'
|
||||
},
|
||||
{
|
||||
label: 'WhatsApp',
|
||||
href: '6'
|
||||
}
|
||||
],
|
||||
socialLinks: {
|
||||
Facebook: '1',
|
||||
Instagram: '2',
|
||||
Snapchat: '3',
|
||||
Twitter: '4',
|
||||
Messenger: '5',
|
||||
WhatsApp: '',
|
||||
LinkedIn: '',
|
||||
GitHub: '',
|
||||
Dev: '',
|
||||
Medium: '',
|
||||
YouTube: '',
|
||||
Twitch: '',
|
||||
Discord: '',
|
||||
Steam: '',
|
||||
Email: ''
|
||||
},
|
||||
buttonLinks: [
|
||||
{
|
||||
id: cuid(),
|
||||
@@ -77,8 +68,6 @@ async function main() {
|
||||
})
|
||||
|
||||
if (!hasData) {
|
||||
console.log(`Start seeding...`)
|
||||
|
||||
await prisma.data.create({
|
||||
data: {
|
||||
id: 1,
|
||||
@@ -90,8 +79,6 @@ async function main() {
|
||||
const hasUser = await prisma.user.findFirst()
|
||||
|
||||
if (!hasUser) {
|
||||
console.log(`Start seeding user...`)
|
||||
|
||||
const { USERNAME, PASSWORD } = process.env
|
||||
|
||||
if (!USERNAME || !PASSWORD) {
|
||||
@@ -102,6 +89,7 @@ async function main() {
|
||||
data: {
|
||||
username: USERNAME,
|
||||
password: hashSync(PASSWORD, 10),
|
||||
lang: 'en',
|
||||
role: 'ADMIN'
|
||||
}
|
||||
})
|
||||
@@ -109,8 +97,8 @@ async function main() {
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { compare } from 'bcryptjs'
|
||||
import { prisma } from 'services/prisma'
|
||||
import { createSession } from 'utils/createSession'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
import { nc } from 'utils/nc'
|
||||
|
||||
const handler = nc.post(async (req, res) => {
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { username, password } = req.body
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -29,6 +30,4 @@ const handler = nc.post(async (req, res) => {
|
||||
const { accessToken, refreshToken } = await createSession(user)
|
||||
|
||||
res.status(200).json({ accessToken, refreshToken })
|
||||
})
|
||||
|
||||
export default handler
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { prisma } from 'services/prisma'
|
||||
import { createSession } from 'utils/createSession'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
import { nc } from 'utils/nc'
|
||||
|
||||
const handler = nc.post(async (req, res) => {
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export async function refreshToken(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { refreshToken } = req.body
|
||||
|
||||
if (!refreshToken) {
|
||||
@@ -43,6 +44,4 @@ const handler = nc.post(async (req, res) => {
|
||||
})
|
||||
|
||||
res.status(200).json({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken })
|
||||
})
|
||||
|
||||
export default handler
|
||||
}
|
||||
16
src/api/data/getData.ts
Normal file
16
src/api/data/getData.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
23
src/api/data/updateData.ts
Normal file
23
src/api/data/updateData.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
13
src/api/index.ts
Normal file
13
src/api/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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'
|
||||
13
src/api/users/checkUsername.ts
Normal file
13
src/api/users/checkUsername.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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 })
|
||||
}
|
||||
50
src/api/users/createUser.ts
Normal file
50
src/api/users/createUser.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
26
src/api/users/deleteUser.ts
Normal file
26
src/api/users/deleteUser.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
23
src/api/users/getUserById.ts
Normal file
23
src/api/users/getUserById.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
20
src/api/users/getUsers.ts
Normal file
20
src/api/users/getUsers.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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,24 +1,23 @@
|
||||
import { prisma } from 'services/prisma'
|
||||
import { authMiddleware } from 'utils/authMiddleware'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
import { nc } from 'utils/nc'
|
||||
|
||||
const handler = nc.use(authMiddleware).get(async (req, res) => {
|
||||
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')
|
||||
throw new ExceptionError('User not found', 404)
|
||||
}
|
||||
|
||||
res.status(200).json(response)
|
||||
})
|
||||
|
||||
export default handler
|
||||
}
|
||||
67
src/api/users/updateUser.ts
Normal file
67
src/api/users/updateUser.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ type ButtonProps = {
|
||||
backgroundColor?: string
|
||||
borderColor?: string
|
||||
borderRadius?: string
|
||||
size?: 'small' | 'medium'
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export const Button = styled.button.attrs((props: ButtonProps) => ({
|
||||
@@ -14,13 +15,15 @@ export const Button = styled.button.attrs((props: ButtonProps) => ({
|
||||
backgroundColor: props.backgroundColor || '#e11d48',
|
||||
borderColor: props.borderColor || '#e11d48',
|
||||
borderRadius: props.borderRadius || 8,
|
||||
size: props.size || 'medium'
|
||||
size: props.size || 'medium',
|
||||
fullWidth: props.fullWidth || false
|
||||
}))<ButtonProps>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2rem;
|
||||
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
|
||||
color: ${({ labelColor }) => labelColor};
|
||||
background: ${({ backgroundColor }) => backgroundColor};
|
||||
border-radius: ${({ borderRadius }) => borderRadius}px;
|
||||
@@ -46,14 +49,22 @@ export const Button = styled.button.attrs((props: ButtonProps) => ({
|
||||
${({ size }) =>
|
||||
size === 'small' &&
|
||||
`
|
||||
height: 2rem;
|
||||
height: 1.8rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
`}
|
||||
|
||||
${({ size }) =>
|
||||
size === 'medium' &&
|
||||
`
|
||||
height: 3rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1rem;
|
||||
`}
|
||||
|
||||
${({ size }) =>
|
||||
size === 'large' &&
|
||||
`
|
||||
height: 3rem;
|
||||
font-size: 1.125rem;
|
||||
`}
|
||||
`
|
||||
|
||||
35
src/components/Dash/AlertModal/index.tsx
Normal file
35
src/components/Dash/AlertModal/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useI18n } from 'hooks/useI18n'
|
||||
|
||||
import { Button } from '../Button'
|
||||
import { Modal } from '../Modal'
|
||||
|
||||
type AlertModalProps = {
|
||||
title: string
|
||||
description: string
|
||||
isOpen: boolean
|
||||
callback: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AlertModal({ title, description, callback, isOpen, onClose }: AlertModalProps) {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={
|
||||
<>
|
||||
<Button outlined onClick={onClose}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button danger onClick={callback}>
|
||||
{t.common.delete}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1
src/components/Dash/Button/index.ts
Normal file
1
src/components/Dash/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Button } from './styles'
|
||||
65
src/components/Dash/Button/styles.ts
Normal file
65
src/components/Dash/Button/styles.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components'
|
||||
import { darken, lighten } from 'polished'
|
||||
|
||||
import { Button as BaseButton } from 'components/Button'
|
||||
|
||||
type ButtonProps = {
|
||||
outlined?: boolean
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export const Button = styled(BaseButton)<ButtonProps>`
|
||||
background: #0284c7;
|
||||
border-color: #0284c7;
|
||||
border-width: 1px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(0);
|
||||
background: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.1, '#0284c7'))};
|
||||
border-color: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.1, '#0284c7'))};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.2, '#0284c7'))};
|
||||
border-color: ${({ outlined }) => (outlined ? '#0284c7' : darken(0.2, '#0284c7'))};
|
||||
}
|
||||
|
||||
${({ outlined }) =>
|
||||
outlined &&
|
||||
`
|
||||
color: #0284c7;
|
||||
background: transparent;
|
||||
border-color: #0284c7;
|
||||
|
||||
&:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
`}
|
||||
|
||||
${({ disabled }) =>
|
||||
disabled &&
|
||||
`
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
`}
|
||||
|
||||
${({ danger, outlined }) =>
|
||||
danger &&
|
||||
`
|
||||
color: ${outlined ? '#e11d48' : '#f8fafc'};
|
||||
background: ${outlined ? 'transparent' : '#e11d48'};;
|
||||
border-color: #e11d48;
|
||||
|
||||
&:hover {
|
||||
color: ${outlined ? '#e11d48' : '#f8fafc'};
|
||||
background: ${outlined ? 'transparent' : darken(0.1, '#e11d48')};
|
||||
border-color: ${outlined ? darken(0.1, '#e11d48') : darken(0.1, '#e11d48')};
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${outlined ? lighten(0.4, '#e11d48') : darken(0.2, '#e11d48')};
|
||||
border-color: ${outlined ? lighten(0.4, '#e11d48') : darken(0.2, '#e11d48')};
|
||||
}
|
||||
`}
|
||||
`
|
||||
19
src/components/Dash/Can/index.tsx
Normal file
19
src/components/Dash/Can/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useCan } from 'hooks/useCan'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Role } from '@prisma/client'
|
||||
|
||||
type CanProps = {
|
||||
children: ReactNode
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export function Can({ children, roles }: CanProps) {
|
||||
const userCanSeeComponents = useCan({ roles })
|
||||
|
||||
if (!userCanSeeComponents) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -16,26 +16,24 @@ type ColorPickerProps = {
|
||||
export function ColorPicker({ prop }: ColorPickerProps) {
|
||||
const { data, setData } = useData()
|
||||
|
||||
const handleChange = (color: string) => {
|
||||
setData({
|
||||
...data,
|
||||
settings: {
|
||||
...data.settings,
|
||||
colors: {
|
||||
...data.settings.colors,
|
||||
[prop]: color
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockPicker
|
||||
color={data.settings.colors[prop]}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
width="100%"
|
||||
onChangeComplete={color => handleChange(color.hex)}
|
||||
onChangeComplete={color =>
|
||||
setData({
|
||||
...data,
|
||||
settings: {
|
||||
...data.settings,
|
||||
colors: {
|
||||
...data.settings.colors,
|
||||
[prop]: color.hex
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
47
src/components/Dash/Modal/index.tsx
Normal file
47
src/components/Dash/Modal/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import ReactModal from 'react-modal'
|
||||
|
||||
import { ButtonsGroup, CloseButton, Header, ModalStyle, OverlayStyle, Title } from './styles'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type ModalProps = {
|
||||
isOpen: boolean
|
||||
onRequestClose: () => void
|
||||
title: string
|
||||
description?: string
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
description,
|
||||
footer,
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
title
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
className="_"
|
||||
overlayClassName="_"
|
||||
contentElement={(props, children) => <ModalStyle {...props}>{children}</ModalStyle>}
|
||||
overlayElement={(props, contentElement) => (
|
||||
<OverlayStyle {...props}>{contentElement}</OverlayStyle>
|
||||
)}
|
||||
>
|
||||
<Header>
|
||||
<Title>{title}</Title>
|
||||
<CloseButton aria-label="Close modal" onClick={onRequestClose} />
|
||||
</Header>
|
||||
|
||||
<p>{description}</p>
|
||||
|
||||
{children}
|
||||
|
||||
{!!footer && <ButtonsGroup>{footer}</ButtonsGroup>}
|
||||
</ReactModal>
|
||||
)
|
||||
}
|
||||
49
src/components/Dash/Modal/styles.ts
Normal file
49
src/components/Dash/Modal/styles.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import styled from 'styled-components'
|
||||
import { FiX } from 'react-icons/fi'
|
||||
|
||||
export const ModalStyle = styled.div`
|
||||
position: fixed;
|
||||
width: 460px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5);
|
||||
outline: none;
|
||||
`
|
||||
|
||||
export const OverlayStyle = styled.div`
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10 !important;
|
||||
`
|
||||
|
||||
export const Header = styled.header`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
`
|
||||
|
||||
export const Title = styled.h2``
|
||||
|
||||
export const CloseButton = styled(FiX)`
|
||||
cursor: pointer;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
`
|
||||
|
||||
export const ButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-self: flex-end;
|
||||
margin-top: 1rem;
|
||||
`
|
||||
@@ -2,7 +2,7 @@ import { useRouter } from 'next/router'
|
||||
|
||||
import { useData } from 'hooks/useData'
|
||||
import { api } from 'services/api'
|
||||
import { removeLocalStorageData } from 'utils/localStorage'
|
||||
import { removeLocalStorage } from 'utils/localStorage'
|
||||
import { Button } from 'components/Button'
|
||||
|
||||
import { ButtonsGroup, Wrapper } from './styles'
|
||||
@@ -27,7 +27,7 @@ export function SaveChangesAlert({
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.put('data', { data })
|
||||
removeLocalStorageData()
|
||||
removeLocalStorage('data')
|
||||
setHasUnsavedChanges(false)
|
||||
setData(data)
|
||||
push('/')
|
||||
@@ -38,7 +38,7 @@ export function SaveChangesAlert({
|
||||
|
||||
const handleCancel = () => {
|
||||
setHasUnsavedChanges(false)
|
||||
removeLocalStorageData()
|
||||
removeLocalStorage('data')
|
||||
setData(initialData)
|
||||
}
|
||||
|
||||
|
||||
17
src/components/Dash/SectionHeader/index.tsx
Normal file
17
src/components/Dash/SectionHeader/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Title, Wrapper } from './styles'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type SectionHeaderProps = {
|
||||
title: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, children }: SectionHeaderProps) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Title>{title}</Title>
|
||||
{children}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
12
src/components/Dash/SectionHeader/styles.ts
Normal file
12
src/components/Dash/SectionHeader/styles.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
`
|
||||
|
||||
export const Title = styled.h2``
|
||||
37
src/components/Dash/SocialLinksSettings/index.tsx
Normal file
37
src/components/Dash/SocialLinksSettings/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'components/Input'
|
||||
import { Icons } from 'components/SocialLinks/icons'
|
||||
import { useData } from 'hooks/useData'
|
||||
|
||||
import type { SocialLinks } from 'types/SocialLinks'
|
||||
|
||||
export function SocialLinksSettings() {
|
||||
const { data, setData } = useData()
|
||||
const socialLinks = Object.entries(data.socialLinks)
|
||||
|
||||
return (
|
||||
<form>
|
||||
{socialLinks.map(([label, href]) => {
|
||||
const Icon = Icons[label as SocialLinks]
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={label}
|
||||
label={label}
|
||||
value={href}
|
||||
icon={Icon}
|
||||
onChange={e => {
|
||||
setData({
|
||||
...data,
|
||||
socialLinks: {
|
||||
// @ts-ignore
|
||||
...data.socialLinks,
|
||||
[label]: e.target.value
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
168
src/components/Dash/UserSettings/AddNewUserModal/index.tsx
Normal file
168
src/components/Dash/UserSettings/AddNewUserModal/index.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { FiLock, FiUser } from 'react-icons/fi'
|
||||
import { MdOutlinePermContactCalendar } from 'react-icons/md'
|
||||
|
||||
import { useDebounce } from 'hooks/useDebounce'
|
||||
import { api } from 'services/api'
|
||||
import { Input } from 'components/Input'
|
||||
import { Select } from 'components/Select'
|
||||
import { useI18n } from 'hooks/useI18n'
|
||||
|
||||
import { Button } from '../../Button'
|
||||
import { Modal } from '../../Modal'
|
||||
|
||||
import { InputsGroup, Wrapper } from './styles'
|
||||
|
||||
import type { User } from 'types/User'
|
||||
import type { FormEvent, Dispatch, SetStateAction } from 'react'
|
||||
|
||||
type AddNewUserModalProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
setUsers: Dispatch<SetStateAction<User[]>>
|
||||
}
|
||||
|
||||
export function AddNewUserModal({ isOpen, onClose, setUsers }: AddNewUserModalProps) {
|
||||
const { t } = useI18n()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const newUserObject = {
|
||||
username: '',
|
||||
password: '',
|
||||
role: 'EDITOR'
|
||||
}
|
||||
|
||||
const errorsObject = {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
|
||||
const [newUser, setNewUser] = useState(newUserObject)
|
||||
const [errors, setErrors] = useState(errorsObject)
|
||||
const [loadingMessage, setLoadingMessage] = useState('')
|
||||
const debouncedUsername = useDebounce(newUser.username)
|
||||
|
||||
const hasErrors =
|
||||
Object.values(errors).some(error => error !== '') ||
|
||||
Object.values(newUser).some(value => value.trim() === '')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.post('users/check-username', {
|
||||
username: debouncedUsername
|
||||
})
|
||||
.then(({ data }) => {
|
||||
if (data.isTaken) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
username: t.userSection.validations.userIsTaken
|
||||
}))
|
||||
} else {
|
||||
setErrors(prev => ({ ...prev, username: '' }))
|
||||
}
|
||||
})
|
||||
.catch(err => console.log(err))
|
||||
.finally(() => setLoadingMessage(''))
|
||||
}, [debouncedUsername, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (newUser.username.trim()) {
|
||||
setLoadingMessage(t.userSection.validations.checking)
|
||||
}
|
||||
}, [newUser.username, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (newUser.password.trim() && newUser.password.length < 8) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
password: t.userSection.validations.passwordIsWeak
|
||||
}))
|
||||
} else {
|
||||
setErrors(prev => ({ ...prev, password: '' }))
|
||||
}
|
||||
}, [newUser.password, t])
|
||||
|
||||
const handleAddNewUser = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const saving = toast.loading(t.common.saving)
|
||||
|
||||
api
|
||||
.post<User>('users', newUser)
|
||||
.then(({ data }) => {
|
||||
setUsers(prev => [...prev, data])
|
||||
setNewUser(newUserObject)
|
||||
onClose()
|
||||
toast.success(t.userSection.newUserModal.success, { id: saving })
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
toast.error(t.userSection.newUserModal.error, { id: saving })
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setNewUser(newUserObject)
|
||||
setErrors(errorsObject)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
title={t.userSection.newUserModal.title}
|
||||
description={t.userSection.newUserModal.description}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={handleClose} outlined>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={hasErrors || isLoading} onClick={handleAddNewUser}>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Wrapper>
|
||||
<InputsGroup>
|
||||
<Input
|
||||
label={t.userSection.common.username}
|
||||
icon={FiUser}
|
||||
value={newUser.username}
|
||||
onChange={e =>
|
||||
setNewUser({
|
||||
...newUser,
|
||||
username: e.target.value.trim().toLocaleLowerCase()
|
||||
})
|
||||
}
|
||||
errorMessage={errors.username}
|
||||
loadingMessage={loadingMessage}
|
||||
/>
|
||||
<Input
|
||||
label={t.userSection.common.password}
|
||||
icon={FiLock}
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
<Select
|
||||
label={t.userSection.common.role}
|
||||
icon={MdOutlinePermContactCalendar}
|
||||
value={newUser.role}
|
||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||
options={[
|
||||
{ value: 'EDITOR', label: t.userSection.common.editor },
|
||||
{ value: 'ADMIN', label: t.userSection.common.admin }
|
||||
]}
|
||||
/>
|
||||
</InputsGroup>
|
||||
</Wrapper>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
14
src/components/Dash/UserSettings/AddNewUserModal/styles.ts
Normal file
14
src/components/Dash/UserSettings/AddNewUserModal/styles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`
|
||||
|
||||
export const InputsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
`
|
||||
159
src/components/Dash/UserSettings/EditUserModal/index.tsx
Normal file
159
src/components/Dash/UserSettings/EditUserModal/index.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { MdOutlinePermContactCalendar } from 'react-icons/md'
|
||||
|
||||
import { api } from 'services/api'
|
||||
import { Input } from 'components/Input'
|
||||
import { Select } from 'components/Select'
|
||||
import { useI18n } from 'hooks/useI18n'
|
||||
|
||||
import { Can } from '../../Can'
|
||||
import { Button } from '../../Button'
|
||||
import { Modal } from '../../Modal'
|
||||
|
||||
import { InputsGroup, Wrapper } from './styles'
|
||||
|
||||
import type { User } from 'types/User'
|
||||
import type { Role } from '@prisma/client'
|
||||
import type { FormEvent, Dispatch, SetStateAction } from 'react'
|
||||
|
||||
type EditUserModalProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user: User
|
||||
}
|
||||
|
||||
export function EditUserModal({ isOpen, onClose, user }: EditUserModalProps) {
|
||||
const { t } = useI18n()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const updatedUserObject = {
|
||||
...user,
|
||||
oldPassword: '',
|
||||
password: ''
|
||||
}
|
||||
|
||||
const errorsObject = {
|
||||
oldPassword: '',
|
||||
password: ''
|
||||
}
|
||||
|
||||
const [updatedUser, setUpdatedUser] = useState(updatedUserObject)
|
||||
const [errors, setErrors] = useState(errorsObject)
|
||||
|
||||
const hasErrors =
|
||||
Object.values(errors).some(error => error !== '') ||
|
||||
Object.values(updatedUser).some(value => !value)
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedUser({
|
||||
...user,
|
||||
oldPassword: '',
|
||||
password: ''
|
||||
})
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (updatedUser.password && !updatedUser.oldPassword) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
oldPassword: t.userSection.validations.oldPasswordIsRequired
|
||||
}))
|
||||
} else {
|
||||
setErrors(prev => ({ ...prev, oldPassword: '' }))
|
||||
}
|
||||
|
||||
if (updatedUser.password.trim() && updatedUser.password.length < 8) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
password: t.userSection.validations.passwordIsWeak
|
||||
}))
|
||||
} else {
|
||||
setErrors(prev => ({ ...prev, password: '' }))
|
||||
}
|
||||
}, [updatedUser, t])
|
||||
|
||||
const handleSaveUpdatedUser = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const saving = toast.loading(t.common.saving)
|
||||
|
||||
api
|
||||
.patch<User>(`users/${user.id}`, updatedUser)
|
||||
.then(() => {
|
||||
setUpdatedUser(updatedUserObject)
|
||||
onClose()
|
||||
toast.success(t.userSection.editUserModal.success, { id: saving })
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
if (err.response.data.error === 'Password does not match') {
|
||||
toast.error(t.userSection.editUserModal.passwordDoesNotMatch, { id: saving })
|
||||
} else {
|
||||
toast.error(t.userSection.editUserModal.error, { id: saving })
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setUpdatedUser(updatedUserObject)
|
||||
setErrors(errorsObject)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
title={t.userSection.editUserModal.title}
|
||||
description={t.userSection.editUserModal.description(updatedUser.username)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={handleClose} outlined>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={hasErrors || isLoading} onClick={handleSaveUpdatedUser}>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Wrapper>
|
||||
<InputsGroup>
|
||||
<Input
|
||||
label={t.userSection.common.oldPassword}
|
||||
type="password"
|
||||
value={updatedUser.oldPassword}
|
||||
onChange={e => setUpdatedUser(prev => ({ ...prev, oldPassword: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.oldPassword}
|
||||
/>
|
||||
<Input
|
||||
label={t.userSection.common.password}
|
||||
type="password"
|
||||
value={updatedUser.password}
|
||||
onChange={e => setUpdatedUser(prev => ({ ...prev, password: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
{user.id !== 1 && (
|
||||
<Can roles={['ADMIN']}>
|
||||
<Select
|
||||
label={t.userSection.common.role}
|
||||
icon={MdOutlinePermContactCalendar}
|
||||
value={updatedUser.role}
|
||||
onChange={e => setUpdatedUser(prev => ({ ...prev, role: e.target.value as Role }))}
|
||||
options={[
|
||||
{ value: 'EDITOR', label: t.userSection.common.editor },
|
||||
{ value: 'ADMIN', label: t.userSection.common.admin }
|
||||
]}
|
||||
/>
|
||||
</Can>
|
||||
)}
|
||||
</InputsGroup>
|
||||
</Wrapper>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
14
src/components/Dash/UserSettings/EditUserModal/styles.ts
Normal file
14
src/components/Dash/UserSettings/EditUserModal/styles.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
`
|
||||
|
||||
export const InputsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
`
|
||||
136
src/components/Dash/UserSettings/index.tsx
Normal file
136
src/components/Dash/UserSettings/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import { FiTrash2 } from 'react-icons/fi'
|
||||
import { HiOutlinePencilAlt } from 'react-icons/hi'
|
||||
|
||||
import { api } from 'services/api'
|
||||
import { useI18n } from 'hooks/useI18n'
|
||||
import { useCan } from 'hooks/useCan'
|
||||
import { useAuth } from 'hooks/useAuth'
|
||||
|
||||
import { Button } from '../Button'
|
||||
import { SectionHeader } from '../SectionHeader'
|
||||
import { AlertModal } from '../AlertModal'
|
||||
|
||||
import { AddNewUserModal } from './AddNewUserModal'
|
||||
import { UserButtonsGroup, UserContent, UserDescription, Users, Wrapper } from './styles'
|
||||
import { EditUserModal } from './EditUserModal'
|
||||
|
||||
import type { User } from 'types/User'
|
||||
|
||||
export function UserSettings() {
|
||||
const { t } = useI18n()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [isNewUserModalOpen, setIsNewUserModalOpen] = useState(false)
|
||||
const [isEditUserModalOpen, setIsEditUserModalOpen] = useState(false)
|
||||
const [isDeleteUserModalOpen, setIsDeleteUserModalOpen] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User>(users[0])
|
||||
const userCan = useCan({ roles: ['ADMIN'] })
|
||||
const { user: authenticatedUser } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('users')
|
||||
.then(({ data }) => setUsers(data))
|
||||
.catch(err => {
|
||||
console.log(err)
|
||||
toast.error(t.userSection.errors.failedToFetch)
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const handleDeleteUser = () => {
|
||||
const { id, username } = selectedUser
|
||||
const deleting = toast.loading(t.common.deleting)
|
||||
setIsDeleteUserModalOpen(false)
|
||||
|
||||
api
|
||||
.delete(`users/${id}`)
|
||||
.then(() => {
|
||||
setUsers(users.filter(user => user.id !== id))
|
||||
toast.success(t.userSection.usersList.userDeleted(username), { id: deleting })
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
toast.error(t.userSection.usersList.errorDeleting, { id: deleting })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<SectionHeader title={t.userSection.title}>
|
||||
{userCan && (
|
||||
<Button size="small" onClick={() => setIsNewUserModalOpen(true)}>
|
||||
{t.userSection.newUserButton}
|
||||
</Button>
|
||||
)}
|
||||
</SectionHeader>
|
||||
<Users>
|
||||
{users.map(user => (
|
||||
<UserContent key={user.id}>
|
||||
<UserDescription>
|
||||
<p>
|
||||
<span>{t.userSection.common.username}: </span>
|
||||
{user.username}
|
||||
</p>
|
||||
<p>
|
||||
<span>{t.userSection.common.role}: </span>
|
||||
{t.userSection.common[user.role.toLowerCase() as 'editor' | 'admin']}
|
||||
</p>
|
||||
</UserDescription>
|
||||
|
||||
<UserButtonsGroup>
|
||||
{(userCan || user.id === authenticatedUser?.id) && (
|
||||
<Button
|
||||
outlined
|
||||
size="small"
|
||||
aria-label={t.userSection.usersList.editUser}
|
||||
onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setIsEditUserModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<HiOutlinePencilAlt />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user.id !== 1 && userCan && (
|
||||
<Button
|
||||
danger
|
||||
outlined
|
||||
size="small"
|
||||
aria-label={t.userSection.usersList.deleteUser}
|
||||
onClick={() => {
|
||||
setSelectedUser(user)
|
||||
setIsDeleteUserModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<FiTrash2 />
|
||||
</Button>
|
||||
)}
|
||||
</UserButtonsGroup>
|
||||
</UserContent>
|
||||
))}
|
||||
</Users>
|
||||
|
||||
<AddNewUserModal
|
||||
isOpen={isNewUserModalOpen}
|
||||
onClose={() => setIsNewUserModalOpen(false)}
|
||||
setUsers={setUsers}
|
||||
/>
|
||||
|
||||
<EditUserModal
|
||||
isOpen={isEditUserModalOpen}
|
||||
onClose={() => setIsEditUserModalOpen(false)}
|
||||
user={selectedUser}
|
||||
/>
|
||||
|
||||
<AlertModal
|
||||
title={t.userSection.deleteUserModal.title}
|
||||
description={t.userSection.deleteUserModal.description(selectedUser?.username)}
|
||||
isOpen={isDeleteUserModalOpen}
|
||||
onClose={() => setIsDeleteUserModalOpen(false)}
|
||||
callback={() => handleDeleteUser()}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
44
src/components/Dash/UserSettings/styles.ts
Normal file
44
src/components/Dash/UserSettings/styles.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.div``
|
||||
|
||||
export const Users = styled.div`
|
||||
list-style: none;
|
||||
`
|
||||
|
||||
export const UserContent = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
|
||||
export const UserDescription = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
span {
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
|
||||
export const UserButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
svg {
|
||||
cursor: pointer;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
`
|
||||
@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react'
|
||||
import Collapsible from 'react-collapsible'
|
||||
|
||||
import { useData } from 'hooks/useData'
|
||||
import { getLocalStorageData, setLocalStorageData } from 'utils/localStorage'
|
||||
import { getLocalStorage, setLocalStorage } from 'utils/localStorage'
|
||||
import { Home } from 'components/Home'
|
||||
import { ColorsSettings } from 'components/Dash/ColorsSettings'
|
||||
import { DataSettings } from 'components/Dash/DataSettings'
|
||||
import { DataSettings } from 'components/Dash/NameInput/DataSettings'
|
||||
import { SaveChangesAlert } from 'components/Dash/SaveChangesAlert'
|
||||
|
||||
import { Content, Preview, Wrapper } from './styles'
|
||||
import { UserSettings } from './UserSettings'
|
||||
import { SocialLinksSettings } from './SocialLinksSettings'
|
||||
|
||||
import type { Data } from 'types/Data'
|
||||
|
||||
@@ -24,7 +26,7 @@ export function Dash({ initialData }: DashProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
const storageData = getLocalStorageData()
|
||||
const storageData = getLocalStorage<Data>('data')
|
||||
if (storageData) setData(storageData)
|
||||
else setData(initialData)
|
||||
|
||||
@@ -33,7 +35,7 @@ export function Dash({ initialData }: DashProps) {
|
||||
|
||||
if (!isLoading && !isEqual(initialData, data)) {
|
||||
setHasUnsavedChanges(true)
|
||||
setLocalStorageData(data)
|
||||
setLocalStorage('data', data)
|
||||
} else {
|
||||
setHasUnsavedChanges(false)
|
||||
}
|
||||
@@ -47,15 +49,17 @@ export function Dash({ initialData }: DashProps) {
|
||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||
initialData={initialData}
|
||||
/>
|
||||
<h1>Dash </h1>
|
||||
<h1>Dash</h1>
|
||||
|
||||
<DataSettings />
|
||||
<ColorsSettings />
|
||||
<UserSettings />
|
||||
{/* <SocialLinksSettings /> */}
|
||||
{/* <DataSettings /> */}
|
||||
{/* <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>
|
||||
</Collapsible> */}
|
||||
</Content>
|
||||
<Preview>
|
||||
<Home />
|
||||
|
||||
@@ -28,6 +28,8 @@ export const Home = () => {
|
||||
borderRadius={buttonBorderRadius}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
size="large"
|
||||
fullWidth
|
||||
>
|
||||
{link.label}
|
||||
</Button>
|
||||
|
||||
40
src/components/Input/index.tsx
Normal file
40
src/components/Input/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Error, Footer, InputWrapper, Label, Loading, Wrapper } from './styles'
|
||||
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
import type { IconType } from 'react-icons/lib'
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string
|
||||
icon?: IconType
|
||||
errorMessage?: string
|
||||
loadingMessage?: string
|
||||
}
|
||||
|
||||
export function Input({
|
||||
label,
|
||||
icon: Icon,
|
||||
errorMessage = '',
|
||||
loadingMessage = '',
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<Wrapper onClick={() => inputRef?.current?.focus()}>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<InputWrapper hasError={!!errorMessage}>
|
||||
{!!Icon && <Icon />}
|
||||
<input ref={inputRef} {...rest} />
|
||||
</InputWrapper>
|
||||
<Footer>
|
||||
{!!loadingMessage ? (
|
||||
<Loading isLoading={!!loadingMessage}>{loadingMessage}</Loading>
|
||||
) : (
|
||||
<Error hasError={!!errorMessage}>{errorMessage}</Error>
|
||||
)}
|
||||
</Footer>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
66
src/components/Input/styles.ts
Normal file
66
src/components/Input/styles.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
`
|
||||
|
||||
export const Label = styled.label`
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-wight: 500;
|
||||
`
|
||||
|
||||
export const InputWrapper = styled.div<{ hasError: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 2.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: ${({ hasError }) => (hasError ? '#f00' : '#ccc')};
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 5px 0 ${({ hasError }) => (hasError ? '#f00' : '#0284c7')};
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::-webkit-contacts-auto-fill-button,
|
||||
&::-webkit-credentials-auto-fill-button {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Footer = styled.div`
|
||||
height: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
`
|
||||
|
||||
export const Loading = styled.span<{ isLoading: boolean }>`
|
||||
opacity: ${({ isLoading }) => (isLoading ? 1 : 0)};
|
||||
`
|
||||
|
||||
export const Error = styled.span<{ hasError: boolean }>`
|
||||
color: #f00;
|
||||
opacity: ${({ hasError }) => (hasError ? 1 : 0)};
|
||||
`
|
||||
37
src/components/Select/index.tsx
Normal file
37
src/components/Select/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { FiArrowDown } from 'react-icons/fi'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { SelectWrapper, Label, Wrapper } from './styles'
|
||||
|
||||
import type { SelectHTMLAttributes } from 'react'
|
||||
import type { IconType } from 'react-icons/lib'
|
||||
|
||||
type InputProps = SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
label?: string
|
||||
icon?: IconType
|
||||
options: {
|
||||
value: string
|
||||
label: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export function Select({ label, icon: Icon, options, ...rest }: InputProps) {
|
||||
const selectRef = useRef<HTMLSelectElement>(null)
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{!!label && <Label>{label}</Label>}
|
||||
<SelectWrapper onClick={() => selectRef?.current?.focus()}>
|
||||
{!!Icon && <Icon />}
|
||||
<select ref={selectRef} {...rest}>
|
||||
{options.map(({ value, label }) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FiArrowDown />
|
||||
</SelectWrapper>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
43
src/components/Select/styles.ts
Normal file
43
src/components/Select/styles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
`
|
||||
|
||||
export const Label = styled.label`
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-wight: 500;
|
||||
`
|
||||
|
||||
export const SelectWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 2.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 5px 0 #0284c7;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
37
src/components/SocialLinks/icons.ts
Normal file
37
src/components/SocialLinks/icons.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
AiFillGithub as GitHub,
|
||||
AiOutlineInstagram as Instagram,
|
||||
AiOutlineWhatsApp as WhatsApp,
|
||||
AiFillFacebook as Facebook,
|
||||
AiFillTwitterSquare as Twitter,
|
||||
AiFillYoutube as YouTube
|
||||
} from 'react-icons/ai'
|
||||
import { BsEnvelope as Email } from 'react-icons/bs'
|
||||
import {
|
||||
FaDev as Dev,
|
||||
FaLinkedinIn as LinkedIn,
|
||||
FaSnapchat as Snapchat,
|
||||
FaMedium as Medium,
|
||||
FaFacebookMessenger as Messenger,
|
||||
FaTwitch as Twitch,
|
||||
FaDiscord as Discord,
|
||||
FaSteam as Steam
|
||||
} from 'react-icons/fa'
|
||||
|
||||
export const Icons = {
|
||||
Facebook,
|
||||
Instagram,
|
||||
Snapchat,
|
||||
Twitter,
|
||||
Messenger,
|
||||
WhatsApp,
|
||||
LinkedIn,
|
||||
GitHub,
|
||||
Dev,
|
||||
Medium,
|
||||
YouTube,
|
||||
Twitch,
|
||||
Discord,
|
||||
Steam,
|
||||
Email
|
||||
}
|
||||
@@ -1,48 +1,38 @@
|
||||
import {
|
||||
AiFillGithub as GitHub,
|
||||
AiOutlineInstagram as Instagram,
|
||||
AiOutlineWhatsApp as WhatsApp
|
||||
} from 'react-icons/ai'
|
||||
import { BsEnvelope as Email } from 'react-icons/bs'
|
||||
import { FaDev as Dev, FaLinkedinIn as LinkedIn } from 'react-icons/fa'
|
||||
|
||||
import { useData } from 'hooks/useData'
|
||||
import { Link } from 'components/Link'
|
||||
|
||||
import { SocialItem, Wrapper } from './styles'
|
||||
import { Icons } from './icons'
|
||||
|
||||
import type { IconType } from 'react-icons/lib'
|
||||
|
||||
const Icons = {
|
||||
Email,
|
||||
GitHub,
|
||||
LinkedIn,
|
||||
Dev,
|
||||
Instagram,
|
||||
WhatsApp
|
||||
}
|
||||
import type { SocialLinks as SocialLinksType } from 'types/SocialLinks'
|
||||
|
||||
export function SocialLinks() {
|
||||
const { data } = useData()
|
||||
const socialLinks = Object.entries(data.socialLinks)
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{data.socialLinks?.map(({ label, href }, i) => {
|
||||
const Icon: IconType = Icons[label as keyof typeof Icons]
|
||||
return (
|
||||
<SocialItem key={i}>
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
color={data.settings.colors.icons}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</Link>
|
||||
</SocialItem>
|
||||
)
|
||||
})}
|
||||
{socialLinks
|
||||
.map(([label, href]) => {
|
||||
if (!href) return
|
||||
const Icon: IconType = Icons[label as SocialLinksType]
|
||||
|
||||
return (
|
||||
<SocialItem key={label}>
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
color={data.settings.colors.icons}
|
||||
>
|
||||
<Icon size={22} />
|
||||
</Link>
|
||||
</SocialItem>
|
||||
)
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const JWT_EXPIRES_IN = 15 // minutes
|
||||
const JWT_EXPIRES_IN = 15000000 // minutes
|
||||
const SESSION_EXPIRES_IN = 30 // days
|
||||
|
||||
const { JWT_SECRET } = process.env
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, createContext, useState } from 'react'
|
||||
import { api } from 'services/api'
|
||||
import { destroyCookies, setCookies } from 'utils/cookies'
|
||||
import { retrieveUser } from 'utils/retrieveUser'
|
||||
import { setLocalStorage } from 'utils/localStorage'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { User } from 'types/User'
|
||||
@@ -28,7 +29,12 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
useEffect(() => {
|
||||
const { accessToken } = parseCookies()
|
||||
if (accessToken) {
|
||||
retrieveUser().then(data => setUser(data))
|
||||
retrieveUser().then(data => {
|
||||
if (data) {
|
||||
setUser(data)
|
||||
setLocalStorage('lang', data.lang || 'en')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -45,7 +51,12 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
})
|
||||
|
||||
retrieveUser()
|
||||
.then(data => setUser(data))
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setUser(data)
|
||||
setLocalStorage('lang', data.lang || 'en')
|
||||
}
|
||||
})
|
||||
.then(() => push('/dash'))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
||||
28
src/contexts/I18nContext.tsx
Normal file
28
src/contexts/I18nContext.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createContext, useEffect, useState } from 'react'
|
||||
|
||||
import { i18n } from 'i18n'
|
||||
import { getLocalStorage } from 'utils/localStorage'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { I18nOptions, I18nProps } from 'i18n'
|
||||
|
||||
type i18nContextType = {
|
||||
t: I18nProps
|
||||
}
|
||||
|
||||
type i18nProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const I18nContext = createContext({} as i18nContextType)
|
||||
|
||||
export const I18nProvider = ({ children }: i18nProviderProps) => {
|
||||
const lang = getLocalStorage<I18nOptions>('lang') || 'en'
|
||||
const [t, setT] = useState(i18n[lang])
|
||||
|
||||
useEffect(() => {
|
||||
setT(i18n[lang])
|
||||
}, [lang])
|
||||
|
||||
return <I18nContext.Provider value={{ t }}>{children}</I18nContext.Provider>
|
||||
}
|
||||
24
src/hooks/useCan.ts
Normal file
24
src/hooks/useCan.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { validateUserPermissions } from 'utils/validateUserPermissions'
|
||||
|
||||
import { useAuth } from './useAuth'
|
||||
|
||||
import type { Role } from '@prisma/client'
|
||||
|
||||
type UseCanPros = {
|
||||
roles: Role[]
|
||||
}
|
||||
|
||||
export function useCan({ roles }: UseCanPros) {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
const userHasValidPermissions = validateUserPermissions({
|
||||
userRole: user.role,
|
||||
roles
|
||||
})
|
||||
|
||||
return userHasValidPermissions
|
||||
}
|
||||
15
src/hooks/useDebounce.ts
Normal file
15
src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useDebounce = (value: string | number, delay = 1000) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
5
src/hooks/useI18n.ts
Normal file
5
src/hooks/useI18n.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { I18nContext } from 'contexts/I18nContext'
|
||||
|
||||
export const useI18n = () => useContext(I18nContext)
|
||||
55
src/i18n/en.ts
Normal file
55
src/i18n/en.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export const en = {
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
saving: 'Saving...',
|
||||
deleting: 'Deleting...'
|
||||
},
|
||||
userSection: {
|
||||
title: 'User management',
|
||||
newUserButton: 'New user',
|
||||
usersList: {
|
||||
title: 'Users list',
|
||||
deleteUser: 'Delete user',
|
||||
editUser: 'Edit user',
|
||||
userDeleted: (user: string) => `User "${user}" deleted.`,
|
||||
errorDeleting: 'Error deleting user.'
|
||||
},
|
||||
editUserModal: {
|
||||
title: 'Edit user',
|
||||
description: (user: string) => `Edit "${user}" user`,
|
||||
success: 'User data updated.',
|
||||
passwordDoesNotMatch: 'Password does not match.',
|
||||
error: 'Error updating user data. Please try again later.'
|
||||
},
|
||||
newUserModal: {
|
||||
title: 'Add new user',
|
||||
description: 'Add new user to the system',
|
||||
success: 'User has been added successfully.',
|
||||
error: 'Error while adding user. Please try again later.'
|
||||
},
|
||||
deleteUserModal: {
|
||||
title: 'Are you sure?',
|
||||
description: (user: string) => `Deleting user "${user}" is an action that cannot be undone`
|
||||
},
|
||||
common: {
|
||||
username: 'Username',
|
||||
oldPassword: 'Old password',
|
||||
password: 'Password',
|
||||
role: 'Role',
|
||||
editor: 'Editor',
|
||||
admin: 'Administrator'
|
||||
},
|
||||
validations: {
|
||||
checking: 'Checking username...',
|
||||
userIsTaken: 'User is already taken.',
|
||||
oldPasswordIsRequired: 'Old password is required.',
|
||||
passwordIsWeak: 'Password must be at least 8 characters long.'
|
||||
},
|
||||
errors: {
|
||||
failedToFetch: 'Failed to fetch users. Are you sure you have internet connection?'
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/i18n/index.ts
Normal file
18
src/i18n/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { en } from './en'
|
||||
|
||||
const i18n = {
|
||||
en
|
||||
}
|
||||
|
||||
const i18nOptions = [
|
||||
{
|
||||
label: 'English',
|
||||
value: 'en'
|
||||
}
|
||||
]
|
||||
|
||||
type I18nProps = typeof en
|
||||
type I18nOptions = keyof typeof i18n
|
||||
|
||||
export { i18n, i18nOptions }
|
||||
export type { I18nProps, I18nOptions }
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
import { Layout } from 'components/Layout'
|
||||
import { AuthProvider } from 'contexts/AuthContext'
|
||||
import { DataProvider } from 'contexts/DataContext'
|
||||
import { I18nProvider } from 'contexts/I18nContext'
|
||||
|
||||
import type { AppProps } from 'next/app'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<DataProvider initialData={pageProps.initialData}>
|
||||
<AuthProvider>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</AuthProvider>
|
||||
</DataProvider>
|
||||
<>
|
||||
<DataProvider initialData={pageProps.initialData}>
|
||||
<AuthProvider>
|
||||
<I18nProvider>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</I18nProvider>
|
||||
</AuthProvider>
|
||||
</DataProvider>
|
||||
<Toaster toastOptions={{ duration: 5000, position: 'top-left' }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
34
src/pages/api/[[...handler]].ts
Normal file
34
src/pages/api/[[...handler]].ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { nc } from 'utils/nc'
|
||||
import { authMiddleware } from 'utils/authMiddleware'
|
||||
import {
|
||||
auth,
|
||||
checkUsername,
|
||||
createUser,
|
||||
deleteUser,
|
||||
getData,
|
||||
getUserById,
|
||||
getUsers,
|
||||
me,
|
||||
refreshToken,
|
||||
updateData,
|
||||
updateUser
|
||||
} from 'api'
|
||||
|
||||
const handler = nc
|
||||
.post('/api/auth', auth)
|
||||
.post('/api/auth/refresh-token', refreshToken)
|
||||
|
||||
.use(authMiddleware)
|
||||
|
||||
.get('/api/data', getData)
|
||||
.put('/api/data', updateData)
|
||||
|
||||
.get('/api/users', getUsers)
|
||||
.get('/api/me', me)
|
||||
.get('/api/users/:id', getUserById)
|
||||
.post('/api/users/check-username', checkUsername)
|
||||
.post('/api/users', createUser)
|
||||
.patch('/api/users/:id', updateUser)
|
||||
.delete('/api/users/:id', deleteUser)
|
||||
|
||||
export default handler
|
||||
@@ -1,38 +0,0 @@
|
||||
import { prisma } from 'services/prisma'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
import { nc } from 'utils/nc'
|
||||
import { authMiddleware } from 'utils/authMiddleware'
|
||||
|
||||
const handler = nc
|
||||
.get(async (_req, res) => {
|
||||
const response = await prisma.data.findUnique({
|
||||
where: { id: 1 }
|
||||
})
|
||||
|
||||
if (!response) {
|
||||
throw new ExceptionError('No data found')
|
||||
}
|
||||
|
||||
res.status(200).json(response?.data)
|
||||
})
|
||||
.use(authMiddleware)
|
||||
.put(async (req, res) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
export default handler
|
||||
@@ -1,126 +0,0 @@
|
||||
import { compare, hashSync } from 'bcryptjs'
|
||||
|
||||
import { nc } from 'utils/nc'
|
||||
import { prisma } from 'services/prisma'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
|
||||
const handler = nc
|
||||
.get(async (req, res) => {
|
||||
const { id } = req.query
|
||||
|
||||
if (!id) {
|
||||
throw new ExceptionError('No id provided')
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
.patch(async (req, res) => {
|
||||
const { id } = req.query
|
||||
const { username, password, newPassword, role } = req.body
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: +id }
|
||||
})
|
||||
|
||||
let updatedUser = {}
|
||||
|
||||
if (!user) {
|
||||
throw new ExceptionError('No user found', 404)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new ExceptionError('No id provided')
|
||||
}
|
||||
|
||||
if (req.userRole !== 'ADMIN' && req.userId !== id) {
|
||||
throw new ExceptionError('Only admins can update other users')
|
||||
}
|
||||
|
||||
if (username) {
|
||||
const usernameExists = await prisma.user.findUnique({
|
||||
where: { username }
|
||||
})
|
||||
|
||||
if (usernameExists && usernameExists.id !== +id) {
|
||||
throw new ExceptionError('Username already exists')
|
||||
}
|
||||
|
||||
updatedUser = { ...updatedUser, username }
|
||||
}
|
||||
|
||||
if (password && newPassword) {
|
||||
const isValidPassword = await compare(password, user.password)
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new ExceptionError('Password does not match')
|
||||
}
|
||||
|
||||
updatedUser = { password: hashSync(newPassword, 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 (user.role === 'EDITOR') {
|
||||
throw new ExceptionError('Cannot change role', 403)
|
||||
}
|
||||
|
||||
updatedUser = { ...updatedUser, role }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await prisma.data.update({
|
||||
where: { id: +id },
|
||||
data: { ...updatedUser }
|
||||
})
|
||||
|
||||
res.status(200).json(response)
|
||||
} catch (err: any) {
|
||||
throw new ExceptionError(err)
|
||||
}
|
||||
})
|
||||
.delete(async (req, res) => {
|
||||
const { id } = req.query
|
||||
|
||||
if (!id) {
|
||||
throw new ExceptionError('No id provided')
|
||||
}
|
||||
|
||||
if (id === '1') {
|
||||
throw new ExceptionError('Cannot delete admin', 403)
|
||||
}
|
||||
|
||||
if (req.userRole !== 'ADMIN' && req.userId !== id) {
|
||||
throw new ExceptionError('Only admins can delete other users')
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.data.delete({
|
||||
where: { id: +id }
|
||||
})
|
||||
|
||||
res.status(204).end()
|
||||
} catch (err: any) {
|
||||
throw new ExceptionError(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default handler
|
||||
@@ -1,63 +0,0 @@
|
||||
import { hashSync } from 'bcryptjs'
|
||||
|
||||
import { prisma } from 'services/prisma'
|
||||
import { authMiddleware } from 'utils/authMiddleware'
|
||||
import { ExceptionError } from 'utils/error'
|
||||
import { nc } from 'utils/nc'
|
||||
|
||||
const handler = nc
|
||||
.use(authMiddleware)
|
||||
.get(async (_req, res) => {
|
||||
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)
|
||||
})
|
||||
.post(async (req, res) => {
|
||||
const { username, password, role } = req.body
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
if (usernameExists) {
|
||||
throw new ExceptionError('Username already exists')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: hashSync(password, 10),
|
||||
role: role ? role : 'EDITOR'
|
||||
}
|
||||
})
|
||||
|
||||
res.status(200).json(response)
|
||||
} catch (err: any) {
|
||||
throw new ExceptionError(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default handler
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Fonts } from './Fonts'
|
||||
import type { SocialLinks } from './SocialLinks'
|
||||
|
||||
export type Data = {
|
||||
settings: {
|
||||
@@ -16,10 +17,7 @@ export type Data = {
|
||||
buttonBorderColor: string
|
||||
}
|
||||
}
|
||||
socialLinks: {
|
||||
label: string
|
||||
href: string
|
||||
}[]
|
||||
socialLinks: SocialLinks
|
||||
buttonLinks: {
|
||||
id: string
|
||||
label: string
|
||||
|
||||
16
src/types/SocialLinks.ts
Normal file
16
src/types/SocialLinks.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type SocialLinks =
|
||||
| 'Facebook'
|
||||
| 'Instagram'
|
||||
| 'Snapchat'
|
||||
| 'Twitter'
|
||||
| 'Messenger'
|
||||
| 'WhatsApp'
|
||||
| 'LinkedIn'
|
||||
| 'GitHub'
|
||||
| 'Dev'
|
||||
| 'Medium'
|
||||
| 'YouTube'
|
||||
| 'Twitch'
|
||||
| 'Discord'
|
||||
| 'Steam'
|
||||
| 'Email'
|
||||
3
src/types/next.d.ts
vendored
3
src/types/next.d.ts
vendored
@@ -4,5 +4,8 @@ declare module 'next' {
|
||||
export interface NextApiRequest extends IncomingMessage {
|
||||
userId: string
|
||||
userRole: string
|
||||
params: {
|
||||
[key: string]: string | string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,20 @@ export function setCookies({ ctx = undefined, accessToken, refreshToken }: SetCo
|
||||
|
||||
setCookie(ctx, 'accessToken', accessToken, {
|
||||
maxAge: SESSION_EXPIRES_IN_S,
|
||||
path: '/'
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
})
|
||||
|
||||
setCookie(ctx, 'refreshToken', refreshToken, {
|
||||
maxAge: SESSION_EXPIRES_IN_S,
|
||||
path: '/'
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
})
|
||||
}
|
||||
|
||||
export function destroyCookies(ctx: GetServerSidePropsContext | undefined = undefined) {
|
||||
destroyCookie(ctx, 'accessToken', {
|
||||
path: '/'
|
||||
})
|
||||
|
||||
destroyCookie(ctx, 'refreshToken', {
|
||||
path: '/'
|
||||
})
|
||||
destroyCookie(ctx, 'accessToken')
|
||||
destroyCookie(ctx, 'refreshToken')
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { Data } from 'types/Data'
|
||||
|
||||
const KEY = 'link-free-data'
|
||||
|
||||
export function getLocalStorageData(): Data | null {
|
||||
const data = process.browser && localStorage.getItem(KEY)
|
||||
export function getLocalStorage<T>(key: string): T | null {
|
||||
const data = process.browser && localStorage.getItem(key)
|
||||
return data ? JSON.parse(data) : null
|
||||
}
|
||||
|
||||
export function setLocalStorageData(data = {}) {
|
||||
process.browser && localStorage.setItem(KEY, JSON.stringify(data))
|
||||
export function setLocalStorage(key: string, data: {} | string) {
|
||||
process.browser && localStorage.setItem(key, JSON.stringify(data))
|
||||
}
|
||||
|
||||
export function removeLocalStorageData() {
|
||||
process.browser && localStorage.removeItem(KEY)
|
||||
export function removeLocalStorage(key: string) {
|
||||
process.browser && localStorage.removeItem(key)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ExceptionError } from 'utils/error'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export const nc = nextConnect<NextApiRequest, NextApiResponse>({
|
||||
attachParams: true,
|
||||
onNoMatch: (_req, res) => {
|
||||
res.status(404).json({ error: 'Not found' })
|
||||
},
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { User } from 'types/User'
|
||||
|
||||
export async function retrieveUser() {
|
||||
try {
|
||||
const { data } = await api.get<User>('users/me')
|
||||
const { data } = await api.get<User>('me')
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
84
yarn.lock
84
yarn.lock
@@ -535,22 +535,22 @@
|
||||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@prisma/client@3.6.0":
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.6.0.tgz#68a60cd4c73a369b11f72e173e86fd6789939293"
|
||||
integrity sha512-ycSGY9EZGROtje0iCNsgC5Zqi/ttX2sO7BNMYaLsUMiTlf3F69ZPH+08pRo0hrDfkZzyimXYqeXJlaoYDH1w7A==
|
||||
"@prisma/client@^3.7.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.7.0.tgz#9cafc105f12635c95e9b7e7b18e8fbf52cf3f18a"
|
||||
integrity sha512-fUJMvBOX5C7JPc0e3CJD6Gbelbu4dMJB4ScYpiht8HMUnRShw20ULOipTopjNtl6ekHQJ4muI7pXlQxWS9nMbw==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
"@prisma/engines-version" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
|
||||
"@prisma/engines-version@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
|
||||
version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#25aa447776849a774885866b998732b37ec4f4f5"
|
||||
integrity sha512-vtoO2ys6mSfc8ONTWdcYztKN3GBU1tcKBj0aXObyjzSuGwHFcM/pEA0xF+n1W4/0TAJgfoPX2khNEit6g0jtNA==
|
||||
"@prisma/engines-version@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#055f36ac8b06c301332c14963cd0d6c795942c90"
|
||||
integrity sha512-+qx2b+HK7BKF4VCa0LZ/t1QCXsu6SmvhUQyJkOD2aPpmOzket4fEnSKQZSB0i5tl7rwCDsvAiSeK8o7rf+yvwg==
|
||||
|
||||
"@prisma/engines@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
|
||||
version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#c68ede6aeffa9ef7743a32cfa6daf9172a4e15b3"
|
||||
integrity sha512-dRClHS7DsTVchDKzeG72OaEyeDskCv91pnZ72Fftn0mp4BkUvX2LvWup65hCNzwwQm5IDd6A88APldKDnMiEMA==
|
||||
"@prisma/engines@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#12f28d5b78519fbd84c89a5bdff457ff5095e7a2"
|
||||
integrity sha512-W549ub5NlgexNhR8EFstA/UwAWq3Zq0w9aNkraqsozVCt2CsX+lK4TK7IW5OZVSnxHwRjrgEAt3r9yPy8nZQRg==
|
||||
|
||||
"@rushstack/eslint-patch@^1.0.8":
|
||||
version "1.1.0"
|
||||
@@ -650,6 +650,13 @@
|
||||
"@types/react" "*"
|
||||
"@types/reactcss" "*"
|
||||
|
||||
"@types/react-modal@^3.13.1":
|
||||
version "3.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a"
|
||||
integrity sha512-iY/gPvTDIy6Z+37l+ibmrY+GTV4KQTHcCyR5FIytm182RQS69G5ps4PH2FxtC7bAQ2QRHXMevsBgck7IQruHNg==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@17.0.37":
|
||||
version "17.0.37"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.37.tgz#6884d0aa402605935c397ae689deed115caad959"
|
||||
@@ -2101,6 +2108,11 @@ execa@^5.0.0, execa@^5.1.1:
|
||||
signal-exit "^3.0.3"
|
||||
strip-final-newline "^2.0.0"
|
||||
|
||||
exenv@^1.2.0:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
|
||||
integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
|
||||
|
||||
expand-template@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
|
||||
@@ -2392,6 +2404,11 @@ globby@^11.0.4:
|
||||
merge2 "^1.3.0"
|
||||
slash "^3.0.0"
|
||||
|
||||
goober@^2.0.35:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.1.tgz#2328a6dae015c3cd30fc55a70090037a244ad2f6"
|
||||
integrity sha512-TkGCqHxE4g5DtdpwxFCi53bXRtvw0BoSgCihVSIOioe9kfkqin5wXG8BQKykN0tjzmxZJ81qU2KWinZf5qKVlw==
|
||||
|
||||
graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0:
|
||||
version "4.2.8"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
|
||||
@@ -3072,7 +3089,7 @@ log-update@^4.0.0:
|
||||
slice-ansi "^4.0.0"
|
||||
wrap-ansi "^6.2.0"
|
||||
|
||||
loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
@@ -3755,12 +3772,12 @@ pretty-quick@3.1.2:
|
||||
mri "^1.1.5"
|
||||
multimatch "^4.0.0"
|
||||
|
||||
prisma@3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.6.0.tgz#99532abc02e045e58c6133a19771bdeb28cecdbe"
|
||||
integrity sha512-6SqgHS/5Rq6HtHjsWsTxlj+ySamGyCLBUQfotc2lStOjPv52IQuDVpp58GieNqc9VnfuFyHUvTZw7aQB+G2fvQ==
|
||||
prisma@^3.7.0:
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.7.0.tgz#9c73eeb2f16f767fdf523d0f4cc4c749734d62e2"
|
||||
integrity sha512-pzgc95msPLcCHqOli7Hnabu/GRfSGSUWl5s2P6N13T/rgMB+NNeKbxCmzQiZT2yLOeLEPivV6YrW1oeQIwJxcg==
|
||||
dependencies:
|
||||
"@prisma/engines" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
"@prisma/engines" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -3960,6 +3977,13 @@ react-dom@17.0.2:
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
|
||||
react-hot-toast@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.1.1.tgz#56409ab406b534e9e58274cf98d80355ba0fdda0"
|
||||
integrity sha512-Odrp4wue0fHh0pOfZt5H+9nWCMtqs3wdlFSzZPp7qsxfzmbE26QmGWIh6hG43CukiPeOjA8WQhBJU8JwtWvWbQ==
|
||||
dependencies:
|
||||
goober "^2.0.35"
|
||||
|
||||
react-icons@4.3.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca"
|
||||
@@ -3975,6 +3999,21 @@ react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-lifecycles-compat@^3.0.0:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
||||
|
||||
react-modal@^3.14.4:
|
||||
version "3.14.4"
|
||||
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.14.4.tgz#2ca7e8e9a180955e5c9508c228b73167c1e6f6a3"
|
||||
integrity sha512-8surmulejafYCH9wfUmFyj4UfbSJwjcgbS9gf3oOItu4Hwd6ivJyVBETI0yHRhpJKCLZMUtnhzk76wXTsNL6Qg==
|
||||
dependencies:
|
||||
exenv "^1.2.0"
|
||||
prop-types "^15.7.2"
|
||||
react-lifecycles-compat "^3.0.0"
|
||||
warning "^4.0.3"
|
||||
|
||||
react-refresh@0.8.3:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
|
||||
@@ -4915,6 +4954,13 @@ vm-browserify@1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
|
||||
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
|
||||
|
||||
warning@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||
dependencies:
|
||||
loose-envify "^1.0.0"
|
||||
|
||||
watchpack@2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.0.tgz#a41bca3da6afaff31e92a433f4c856a0c25ea0c4"
|
||||
|
||||
Reference in New Issue
Block a user