feat: add user management section

This commit is contained in:
Daniel Soares
2021-12-23 14:51:34 -03:00
parent f977de999d
commit 19aa8d2311
64 changed files with 1713 additions and 398 deletions

View File

@@ -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"

View File

@@ -16,6 +16,7 @@ model User {
id Int @id @default(autoincrement())
username String @unique
password String
lang String @default("en")
role Role
}

View File

@@ -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 () => {

View File

@@ -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
}

View File

@@ -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
View 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)
}

View 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
View 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'

View 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 })
}

View 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)
}
}

View 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)
}
}

View 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
View 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)
}

View File

@@ -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
}

View 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)
}
}

View File

@@ -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;
`}
`

View 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>
</>
}
/>
)
}

View File

@@ -0,0 +1 @@
export { Button } from './styles'

View 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')};
}
`}
`

View 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}</>
}

View File

@@ -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
}
}
})
}
/>
)
}

View 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>
)
}

View 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;
`

View File

@@ -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)
}

View 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>
)
}

View 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``

View 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>
)
}

View 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>
)
}

View 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%;
`

View 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>
)
}

View 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%;
`

View 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>
)
}

View 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;
}
`

View File

@@ -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 />

View File

@@ -28,6 +28,8 @@ export const Home = () => {
borderRadius={buttonBorderRadius}
rel="noopener noreferrer"
target="_blank"
size="large"
fullWidth
>
{link.label}
</Button>

View 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>
)
}

View 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)};
`

View 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>
)
}

View 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;
}
}
`

View 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
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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)

View 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
View 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
View 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
View 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
View 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
View 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 }

View File

@@ -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' }} />
</>
)
}

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -4,5 +4,8 @@ declare module 'next' {
export interface NextApiRequest extends IncomingMessage {
userId: string
userRole: string
params: {
[key: string]: string | string[]
}
}
}

View File

@@ -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')
}

View File

@@ -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)
}

View File

@@ -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' })
},

View File

@@ -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)
}
}

View File

@@ -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"