diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdbdedb0..30dd84a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,10 @@ We want to leverage the powers of NextJS as much as possible. And one thing it d One of our goals with this project is to keep it as componentized as we can. Any element that's used in more than one place should be created as a separate component. This will help us create consistency and avoid creating the same or very similar components in multiple files. +## Restricted Pages + +Some pages we only want users to be able to access after they've successfully authenticated. We gatekeep these pages using the `RestrictedPage` server component. When you create a new page that unathenticated users should not be able to access, wrap it in this component. + ## Types This project uses and relies on TypeScript. Meaning every variable, function, and component is required to have proper type definitions. @@ -60,5 +64,3 @@ Note that end-to-end tests have not yet been set up. If you're passionate about ## Pull Requests The pull request flow in this project isn't anything special. We require a pull request to be created before anything is merged into `main`. At least one person must approve the pull request. - - diff --git a/next.config.js b/next.config.js index 7521d057..8c2cc8c8 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,10 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + experimental: { + serverActions: { + allowedOrigins: ['localhost'], + } + }, images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index 5f946ce6..7741b0e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,14 @@ "@material-tailwind/react": "^2.1.9", "@uiw/react-md-editor": "^4.0.4", "classnames": "^2.5.1", - "cross-fetch": "3.1.8", + "cross-fetch": "^4.0.0", "js-cookie": "^3.0.5", "next": "14.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", - "sublinks-js-client": "^0.0.13", + "sublinks-js-client": "^0.0.17", "sublinks-markdown": "^0.1.4" }, "devDependencies": { @@ -3968,9 +3968,9 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", - "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", "dependencies": { "node-fetch": "^2.6.12" } @@ -11251,9 +11251,9 @@ } }, "node_modules/sublinks-js-client": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/sublinks-js-client/-/sublinks-js-client-0.0.13.tgz", - "integrity": "sha512-qMqPhVvln0N+lUew/fMQUur2DB3R3yr0fbTxmW2JrNftYL80cRCCapF1L+WfMTDe8agtS4+vaK6VG5M+q6HztQ==" + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/sublinks-js-client/-/sublinks-js-client-0.0.17.tgz", + "integrity": "sha512-OPfvvqdDWD/CGO12bdBcEHUGTIj7Taqu3aV4gOpd9kjV7i/xjoRKLtxyHj/wGfkKIWlwBwOygtOMScIem86r1Q==" }, "node_modules/sublinks-markdown": { "version": "0.1.4", diff --git a/package.json b/package.json index 7f848ffb..afad3b0b 100644 --- a/package.json +++ b/package.json @@ -21,14 +21,14 @@ "@material-tailwind/react": "^2.1.9", "@uiw/react-md-editor": "^4.0.4", "classnames": "^2.5.1", - "cross-fetch": "3.1.8", + "cross-fetch": "^4.0.0", "js-cookie": "^3.0.5", "next": "14.2.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", - "sublinks-js-client": "^0.0.13", + "sublinks-js-client": "^0.0.17", "sublinks-markdown": "^0.1.4" }, "devDependencies": { diff --git a/src/app/c/create/page.tsx b/src/app/c/create/page.tsx index 446e7f5b..686e731d 100644 --- a/src/app/c/create/page.tsx +++ b/src/app/c/create/page.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import RestrictedPage from '@/components/auth/restricted-page'; import { H1 } from '@/components/text'; import CommunityForm from '@/components/form/community'; @@ -14,4 +15,4 @@ const Communities = () => ( ); -export default Communities; +export default () => RestrictedPage(); diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index f9dd3e55..c64799e0 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -2,11 +2,12 @@ import React, { useContext, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { Spinner } from '@material-tailwind/react'; import SublinksApi from '@/utils/api-client/client'; import { UserContext } from '@/context/user'; -import { Spinner } from '@material-tailwind/react'; import { BodyTitle } from '@/components/text'; +import { revalidateAll } from '@/utils/server'; const Logout = () => { const router = useRouter(); @@ -17,6 +18,7 @@ const Logout = () => { const logout = async () => { await SublinksApi.Instance().logout(); clearMyUser(); + revalidateAll(); router.replace('/'); }; diff --git a/src/app/p/page.tsx b/src/app/p/page.tsx index b7046ffd..23336651 100644 --- a/src/app/p/page.tsx +++ b/src/app/p/page.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import SublinksApi from '@/utils/api-client/server'; +import RestrictedPage from '@/components/auth/restricted-page'; import { ErrorText, H1 } from '@/components/text'; import PostForm from '@/components/form/post'; +import SublinksApi from '@/utils/api-client/server'; import logger from '@/utils/logger'; const getCommunities = async () => { @@ -39,4 +40,4 @@ const PostCreate = async () => { ); }; -export default PostCreate; +export default () => RestrictedPage(); diff --git a/src/components/auth/restricted-page.tsx b/src/components/auth/restricted-page.tsx new file mode 100644 index 00000000..e3267918 --- /dev/null +++ b/src/components/auth/restricted-page.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { redirect } from 'next/navigation'; +import { cookies } from 'next/headers'; + +import SublinksApi from '@/utils/api-client/server'; +import { AUTH_COOKIE_NAME } from '@/utils/api-client/base'; + +const RestrictedPage = async (page: React.JSX.Element) => { + const authCookie = cookies().get(AUTH_COOKIE_NAME); + if (!authCookie?.value) { + redirect('/login'); + } + + const validation = await SublinksApi.Instance().Client().validateAuth(); + if (!validation.success) { + redirect('/login'); + } + + return page; +}; + +export default RestrictedPage; diff --git a/src/components/form/community.tsx b/src/components/form/community.tsx index c1f3e1c6..1be4d9aa 100644 --- a/src/components/form/community.tsx +++ b/src/components/form/community.tsx @@ -1,8 +1,6 @@ 'use client'; -import React, { - FormEvent, useContext, useEffect, useState -} from 'react'; +import React, { FormEvent, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Checkbox, InputField, MarkdownTextarea } from '@/components/input'; @@ -11,7 +9,6 @@ import { BodyTitleInverse, ErrorText, PaleBodyText } from '@/components/text'; import SublinksApi from '@/utils/api-client/client'; import logger from '@/utils/logger'; import { Spinner } from '@material-tailwind/react'; -import { UserContext } from '@/context/user'; const INPUT_IDS = { NAME: 'name', @@ -30,17 +27,10 @@ const REQUIRED_FIELDS = [ const CommunityForm = () => { const router = useRouter(); - const { userData } = useContext(UserContext); const [erroneousFields, setErroneousFields] = useState([]); const [errorMessage, setErrorMessage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - useEffect(() => { - if (userData.auth === false) { - router.push('/login'); - } - }, [userData]); // eslint-disable-line react-hooks/exhaustive-deps - const validateRequiredFields = (fieldValues: Record) => { const emptyFields: string[] = []; @@ -68,13 +58,11 @@ const CommunityForm = () => { return undefined; }; - const handleCreationAttempt = async (event: FormEvent) => { - event.preventDefault(); + const communityCreateAction = async (formData: FormData) => { setIsSubmitting(true); setErrorMessage(''); setErroneousFields([]); - const formData = new FormData(event.currentTarget); const fieldValues = { name: formData.get(INPUT_IDS.NAME) as string, title: formData.get(INPUT_IDS.TITLE) as string, @@ -138,7 +126,7 @@ const CommunityForm = () => { }; return ( -
+
{ } }, [userData]); // eslint-disable-line react-hooks/exhaustive-deps - const handleLoginAttempt = async (event: FormEvent) => { - event.preventDefault(); + const loginAction = async (formData: FormData) => { setIsSubmitting(true); setErrorMessage(''); setErroneousFields([]); - const formData = new FormData(event.currentTarget); const fieldValues = { username: formData.get('username') as string, password: formData.get('password') as string @@ -65,6 +64,7 @@ const LoginForm = () => { password: fieldValues.password }); await saveMyUserFromSite(); + revalidateAll(); router.push('/'); } catch (e) { logger.error('Login attempt failed', e); @@ -90,7 +90,7 @@ const LoginForm = () => { }; return ( - +
{ const router = useRouter(); - const { userData } = useContext(UserContext); const [erroneousFields, setErroneousFields] = useState([]); const [errorMessage, setErrorMessage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [isMediaPost, setIsMediaPost] = useState(false); - useEffect(() => { - if (userData.auth === false) { - router.push('/login'); - } - }, [userData]); // eslint-disable-line react-hooks/exhaustive-deps - const validateRequiredFields = (fieldValues: Record) => { const emptyFields: string[] = []; @@ -77,13 +67,11 @@ const PostForm = ({ communities }: PostFormProps) => { return undefined; }; - const handleCreationAttempt = async (event: FormEvent) => { - event.preventDefault(); + const postCreateAction = async (formData: FormData) => { setIsSubmitting(true); setErrorMessage(''); setErroneousFields([]); - const formData = new FormData(event.currentTarget); const fieldValues = { community: parseInt(formData.get(INPUT_IDS.COMMUNITY) as string, 10), title: formData.get(INPUT_IDS.TITLE) as string, @@ -152,7 +140,7 @@ const PostForm = ({ communities }: PostFormProps) => { })); return ( - +
{ } }, [userData]); // eslint-disable-line react-hooks/exhaustive-deps - const handleSignupAttempt = async (event: FormEvent) => { - event.preventDefault(); + const signUpAction = async (formData: FormData) => { setIsSubmitting(true); setErrorMessage(''); setErroneousFields([]); - const formData = new FormData(event.currentTarget); const fieldValues = { email: formData.get('email') as string, username: formData.get('username') as string, @@ -113,7 +111,7 @@ const SignupForm = () => { }; return ( - +
revalidatePath('/', 'layout');