diff --git a/package.json b/package.json index fa1b13d..29bd8ed 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8cfb0d4..a8678eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { id Int @id @default(autoincrement()) username String @unique password String + lang String @default("en") role Role } diff --git a/prisma/seed.ts b/prisma/seed.ts index e3f7c63..1dcbed1 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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 () => { diff --git a/src/pages/api/auth/index.ts b/src/api/auth/index.ts similarity index 84% rename from src/pages/api/auth/index.ts rename to src/api/auth/index.ts index 9a7e570..2e1332d 100644 --- a/src/pages/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -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 +} diff --git a/src/pages/api/auth/refresh-token.ts b/src/api/auth/refreshToken.ts similarity index 88% rename from src/pages/api/auth/refresh-token.ts rename to src/api/auth/refreshToken.ts index 66cd3a6..cd2100b 100644 --- a/src/pages/api/auth/refresh-token.ts +++ b/src/api/auth/refreshToken.ts @@ -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 +} diff --git a/src/api/data/getData.ts b/src/api/data/getData.ts new file mode 100644 index 0000000..b5eafef --- /dev/null +++ b/src/api/data/getData.ts @@ -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) +} diff --git a/src/api/data/updateData.ts b/src/api/data/updateData.ts new file mode 100644 index 0000000..b81a4f1 --- /dev/null +++ b/src/api/data/updateData.ts @@ -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) + } +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..824402b --- /dev/null +++ b/src/api/index.ts @@ -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' diff --git a/src/api/users/checkUsername.ts b/src/api/users/checkUsername.ts new file mode 100644 index 0000000..a33860e --- /dev/null +++ b/src/api/users/checkUsername.ts @@ -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 }) +} diff --git a/src/api/users/createUser.ts b/src/api/users/createUser.ts new file mode 100644 index 0000000..24059db --- /dev/null +++ b/src/api/users/createUser.ts @@ -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) + } +} diff --git a/src/api/users/deleteUser.ts b/src/api/users/deleteUser.ts new file mode 100644 index 0000000..674c283 --- /dev/null +++ b/src/api/users/deleteUser.ts @@ -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) + } +} diff --git a/src/api/users/getUserById.ts b/src/api/users/getUserById.ts new file mode 100644 index 0000000..deff7bb --- /dev/null +++ b/src/api/users/getUserById.ts @@ -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) +} diff --git a/src/api/users/getUsers.ts b/src/api/users/getUsers.ts new file mode 100644 index 0000000..9716616 --- /dev/null +++ b/src/api/users/getUsers.ts @@ -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) +} diff --git a/src/pages/api/users/me.ts b/src/api/users/me.ts similarity index 59% rename from src/pages/api/users/me.ts rename to src/api/users/me.ts index 4ec280f..3c1b63b 100644 --- a/src/pages/api/users/me.ts +++ b/src/api/users/me.ts @@ -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 +} diff --git a/src/api/users/updateUser.ts b/src/api/users/updateUser.ts new file mode 100644 index 0000000..eef7f12 --- /dev/null +++ b/src/api/users/updateUser.ts @@ -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) + } +} diff --git a/src/components/Button/index.tsx b/src/components/Button/index.ts similarity index 100% rename from src/components/Button/index.tsx rename to src/components/Button/index.ts diff --git a/src/components/Button/styles.ts b/src/components/Button/styles.ts index fa6a479..16e1d45 100644 --- a/src/components/Button/styles.ts +++ b/src/components/Button/styles.ts @@ -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 }))` 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; + `} ` diff --git a/src/components/Dash/AlertModal/index.tsx b/src/components/Dash/AlertModal/index.tsx new file mode 100644 index 0000000..ddcc025 --- /dev/null +++ b/src/components/Dash/AlertModal/index.tsx @@ -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 ( + + + + + } + /> + ) +} diff --git a/src/components/Dash/Button/index.ts b/src/components/Dash/Button/index.ts new file mode 100644 index 0000000..4a15367 --- /dev/null +++ b/src/components/Dash/Button/index.ts @@ -0,0 +1 @@ +export { Button } from './styles' diff --git a/src/components/Dash/Button/styles.ts b/src/components/Dash/Button/styles.ts new file mode 100644 index 0000000..258c28f --- /dev/null +++ b/src/components/Dash/Button/styles.ts @@ -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)` + 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')}; + } + `} +` diff --git a/src/components/Dash/Can/index.tsx b/src/components/Dash/Can/index.tsx new file mode 100644 index 0000000..d37e2b0 --- /dev/null +++ b/src/components/Dash/Can/index.tsx @@ -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} +} diff --git a/src/components/Dash/ColorPicker/index.tsx b/src/components/Dash/ColorPicker/index.tsx index a1c6e7a..93a4734 100644 --- a/src/components/Dash/ColorPicker/index.tsx +++ b/src/components/Dash/ColorPicker/index.tsx @@ -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 ( handleChange(color.hex)} + onChangeComplete={color => + setData({ + ...data, + settings: { + ...data.settings, + colors: { + ...data.settings.colors, + [prop]: color.hex + } + } + }) + } /> ) } diff --git a/src/components/Dash/Modal/index.tsx b/src/components/Dash/Modal/index.tsx new file mode 100644 index 0000000..e5b5569 --- /dev/null +++ b/src/components/Dash/Modal/index.tsx @@ -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 ( + {children}} + overlayElement={(props, contentElement) => ( + {contentElement} + )} + > +
+ {title} + +
+ +

{description}

+ + {children} + + {!!footer && {footer}} +
+ ) +} diff --git a/src/components/Dash/Modal/styles.ts b/src/components/Dash/Modal/styles.ts new file mode 100644 index 0000000..9cae2e0 --- /dev/null +++ b/src/components/Dash/Modal/styles.ts @@ -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; +` diff --git a/src/components/Dash/DataSettings/index.tsx b/src/components/Dash/NameInput/DataSettings/index.tsx similarity index 100% rename from src/components/Dash/DataSettings/index.tsx rename to src/components/Dash/NameInput/DataSettings/index.tsx diff --git a/src/components/Dash/SaveChangesAlert/index.tsx b/src/components/Dash/SaveChangesAlert/index.tsx index 5b11d88..b48262b 100644 --- a/src/components/Dash/SaveChangesAlert/index.tsx +++ b/src/components/Dash/SaveChangesAlert/index.tsx @@ -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) } diff --git a/src/components/Dash/SectionHeader/index.tsx b/src/components/Dash/SectionHeader/index.tsx new file mode 100644 index 0000000..985cd23 --- /dev/null +++ b/src/components/Dash/SectionHeader/index.tsx @@ -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 ( + + {title} + {children} + + ) +} diff --git a/src/components/Dash/SectionHeader/styles.ts b/src/components/Dash/SectionHeader/styles.ts new file mode 100644 index 0000000..f865ec7 --- /dev/null +++ b/src/components/Dash/SectionHeader/styles.ts @@ -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`` diff --git a/src/components/Dash/SocialLinksSettings/index.tsx b/src/components/Dash/SocialLinksSettings/index.tsx new file mode 100644 index 0000000..d147e06 --- /dev/null +++ b/src/components/Dash/SocialLinksSettings/index.tsx @@ -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 ( +
+ {socialLinks.map(([label, href]) => { + const Icon = Icons[label as SocialLinks] + + return ( + { + setData({ + ...data, + socialLinks: { + // @ts-ignore + ...data.socialLinks, + [label]: e.target.value + } + }) + }} + /> + ) + })} +
+ ) +} diff --git a/src/components/Dash/UserSettings/AddNewUserModal/index.tsx b/src/components/Dash/UserSettings/AddNewUserModal/index.tsx new file mode 100644 index 0000000..dbdb198 --- /dev/null +++ b/src/components/Dash/UserSettings/AddNewUserModal/index.tsx @@ -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> +} + +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('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 ( + + + + + } + > + + + + setNewUser({ + ...newUser, + username: e.target.value.trim().toLocaleLowerCase() + }) + } + errorMessage={errors.username} + loadingMessage={loadingMessage} + /> + setNewUser({ ...newUser, password: e.target.value })} + autoComplete="new-password" + errorMessage={errors.password} + /> + setUpdatedUser(prev => ({ ...prev, oldPassword: e.target.value }))} + autoComplete="new-password" + errorMessage={errors.oldPassword} + /> + setUpdatedUser(prev => ({ ...prev, password: e.target.value }))} + autoComplete="new-password" + errorMessage={errors.password} + /> + {user.id !== 1 && ( + + + +
+ {!!loadingMessage ? ( + {loadingMessage} + ) : ( + {errorMessage} + )} +
+
+ ) +} diff --git a/src/components/Input/styles.ts b/src/components/Input/styles.ts new file mode 100644 index 0000000..4dbba5b --- /dev/null +++ b/src/components/Input/styles.ts @@ -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)}; +` diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000..091ee55 --- /dev/null +++ b/src/components/Select/index.tsx @@ -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 & { + label?: string + icon?: IconType + options: { + value: string + label: string + }[] +} + +export function Select({ label, icon: Icon, options, ...rest }: InputProps) { + const selectRef = useRef(null) + + return ( + + {!!label && } + selectRef?.current?.focus()}> + {!!Icon && } + + + + + ) +} diff --git a/src/components/Select/styles.ts b/src/components/Select/styles.ts new file mode 100644 index 0000000..c908c86 --- /dev/null +++ b/src/components/Select/styles.ts @@ -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; + } + } +` diff --git a/src/components/SocialLinks/icons.ts b/src/components/SocialLinks/icons.ts new file mode 100644 index 0000000..a5028b6 --- /dev/null +++ b/src/components/SocialLinks/icons.ts @@ -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 +} diff --git a/src/components/SocialLinks/index.tsx b/src/components/SocialLinks/index.tsx index e01d135..cc801c4 100644 --- a/src/components/SocialLinks/index.tsx +++ b/src/components/SocialLinks/index.tsx @@ -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 ( - {data.socialLinks?.map(({ label, href }, i) => { - const Icon: IconType = Icons[label as keyof typeof Icons] - return ( - - - - - - ) - })} + {socialLinks + .map(([label, href]) => { + if (!href) return + const Icon: IconType = Icons[label as SocialLinksType] + + return ( + + + + + + ) + }) + .filter(Boolean)} ) } diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 8e63ec3..640164c 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -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 diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 00ec490..edbe491 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -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) diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx new file mode 100644 index 0000000..d0edeeb --- /dev/null +++ b/src/contexts/I18nContext.tsx @@ -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('lang') || 'en' + const [t, setT] = useState(i18n[lang]) + + useEffect(() => { + setT(i18n[lang]) + }, [lang]) + + return {children} +} diff --git a/src/hooks/useCan.ts b/src/hooks/useCan.ts new file mode 100644 index 0000000..85c1882 --- /dev/null +++ b/src/hooks/useCan.ts @@ -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 +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..43cdf19 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -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 +} diff --git a/src/hooks/useI18n.ts b/src/hooks/useI18n.ts new file mode 100644 index 0000000..775ce85 --- /dev/null +++ b/src/hooks/useI18n.ts @@ -0,0 +1,5 @@ +import { useContext } from 'react' + +import { I18nContext } from 'contexts/I18nContext' + +export const useI18n = () => useContext(I18nContext) diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..6084909 --- /dev/null +++ b/src/i18n/en.ts @@ -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?' + } + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..9226bb6 --- /dev/null +++ b/src/i18n/index.ts @@ -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 } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5d84023..219f21b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -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 ( - - - - - - - + <> + + + + + + + + + + + ) } diff --git a/src/pages/api/[[...handler]].ts b/src/pages/api/[[...handler]].ts new file mode 100644 index 0000000..369eefc --- /dev/null +++ b/src/pages/api/[[...handler]].ts @@ -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 diff --git a/src/pages/api/data.ts b/src/pages/api/data.ts deleted file mode 100644 index ea3a680..0000000 --- a/src/pages/api/data.ts +++ /dev/null @@ -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 diff --git a/src/pages/api/users/[id].ts b/src/pages/api/users/[id].ts deleted file mode 100644 index 9cbf124..0000000 --- a/src/pages/api/users/[id].ts +++ /dev/null @@ -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 diff --git a/src/pages/api/users/index.ts b/src/pages/api/users/index.ts deleted file mode 100644 index 3c68a02..0000000 --- a/src/pages/api/users/index.ts +++ /dev/null @@ -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 diff --git a/src/types/Data.ts b/src/types/Data.ts index 86e0741..2eb210a 100644 --- a/src/types/Data.ts +++ b/src/types/Data.ts @@ -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 diff --git a/src/types/SocialLinks.ts b/src/types/SocialLinks.ts new file mode 100644 index 0000000..8c9347f --- /dev/null +++ b/src/types/SocialLinks.ts @@ -0,0 +1,16 @@ +export type SocialLinks = + | 'Facebook' + | 'Instagram' + | 'Snapchat' + | 'Twitter' + | 'Messenger' + | 'WhatsApp' + | 'LinkedIn' + | 'GitHub' + | 'Dev' + | 'Medium' + | 'YouTube' + | 'Twitch' + | 'Discord' + | 'Steam' + | 'Email' diff --git a/src/types/next.d.ts b/src/types/next.d.ts index c5cdde4..69128b3 100644 --- a/src/types/next.d.ts +++ b/src/types/next.d.ts @@ -4,5 +4,8 @@ declare module 'next' { export interface NextApiRequest extends IncomingMessage { userId: string userRole: string + params: { + [key: string]: string | string[] + } } } diff --git a/src/utils/cookies.ts b/src/utils/cookies.ts index 55c293d..ebda23e 100644 --- a/src/utils/cookies.ts +++ b/src/utils/cookies.ts @@ -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') } diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index e0dd722..f3719bd 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -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(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) } diff --git a/src/utils/nc.ts b/src/utils/nc.ts index 2af7b89..7019152 100644 --- a/src/utils/nc.ts +++ b/src/utils/nc.ts @@ -6,6 +6,7 @@ import { ExceptionError } from 'utils/error' import type { NextApiRequest, NextApiResponse } from 'next' export const nc = nextConnect({ + attachParams: true, onNoMatch: (_req, res) => { res.status(404).json({ error: 'Not found' }) }, diff --git a/src/utils/retrieveUser.ts b/src/utils/retrieveUser.ts index 9568305..97a29f8 100644 --- a/src/utils/retrieveUser.ts +++ b/src/utils/retrieveUser.ts @@ -4,9 +4,9 @@ import type { User } from 'types/User' export async function retrieveUser() { try { - const { data } = await api.get('users/me') + const { data } = await api.get('me') return data - } catch (error) { - console.log(error) + } catch (err) { + console.log(err) } } diff --git a/yarn.lock b/yarn.lock index b24a5d1..20616c2 100644 --- a/yarn.lock +++ b/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"