Skip to content

Commit

Permalink
Create auth-restricted pages (#153)
Browse files Browse the repository at this point in the history
* Create middleware

* Update client and fetch

* Bind fetch to window

* Ignore cookie presence

* Remove login redirect from comm form

* Bind fetch to global

* Use request cookies to pre-check auth

* Create server actions util

* Create page restriction component

* Allow server actions from localhost

* Restrict access for entity creation

* Use form actions

* Update docs
  • Loading branch information
kgilles authored May 17, 2024
1 parent 15af7e3 commit ce3574b
Show file tree
Hide file tree
Showing 14 changed files with 69 additions and 57 deletions.
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.


5 changes: 5 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
allowedOrigins: ['localhost'],
}
},
images: {
remotePatterns: [
{
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/app/c/create/page.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -14,4 +15,4 @@ const Communities = () => (
</div>
);

export default Communities;
export default () => RestrictedPage(<Communities />);
4 changes: 3 additions & 1 deletion src/app/logout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,6 +18,7 @@ const Logout = () => {
const logout = async () => {
await SublinksApi.Instance().logout();
clearMyUser();
revalidateAll();
router.replace('/');
};

Expand Down
5 changes: 3 additions & 2 deletions src/app/p/page.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -39,4 +40,4 @@ const PostCreate = async () => {
);
};

export default PostCreate;
export default () => RestrictedPage(<PostCreate />);
22 changes: 22 additions & 0 deletions src/components/auth/restricted-page.tsx
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 3 additions & 15 deletions src/components/form/community.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand All @@ -30,17 +27,10 @@ const REQUIRED_FIELDS = [

const CommunityForm = () => {
const router = useRouter();
const { userData } = useContext(UserContext);
const [erroneousFields, setErroneousFields] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string>('');
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<string, string | File>) => {
const emptyFields: string[] = [];

Expand Down Expand Up @@ -68,13 +58,11 @@ const CommunityForm = () => {
return undefined;
};

const handleCreationAttempt = async (event: FormEvent<HTMLFormElement>) => {
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,
Expand Down Expand Up @@ -138,7 +126,7 @@ const CommunityForm = () => {
};

return (
<form onSubmit={handleCreationAttempt} onChange={handleFieldValueChange} className="flex flex-col">
<form action={communityCreateAction} onChange={handleFieldValueChange} className="flex flex-col">
<div className="flex flex-col gap-16">
<div>
<InputField
Expand Down
10 changes: 5 additions & 5 deletions src/components/form/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import React, {
FormEvent, useContext, useEffect, useState
} from 'react';
import { useRouter } from 'next/navigation';
import { Spinner } from '@material-tailwind/react';

import { InputField } from '@/components/input';
import Button from '@/components/button';
import SublinksApi from '@/utils/api-client/client';
import { UserContext } from '@/context/user';
import logger from '@/utils/logger';
import { Spinner } from '@material-tailwind/react';
import { revalidateAll } from '@/utils/server';
import { BodyTitleInverse, ErrorText } from '../text';

const LOGIN_FIELD_IDS = {
Expand All @@ -31,13 +32,11 @@ const LoginForm = () => {
}
}, [userData]); // eslint-disable-line react-hooks/exhaustive-deps

const handleLoginAttempt = async (event: FormEvent<HTMLFormElement>) => {
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
Expand Down Expand Up @@ -65,6 +64,7 @@ const LoginForm = () => {
password: fieldValues.password
});
await saveMyUserFromSite();
revalidateAll();
router.push('/');
} catch (e) {
logger.error('Login attempt failed', e);
Expand All @@ -90,7 +90,7 @@ const LoginForm = () => {
};

return (
<form onSubmit={handleLoginAttempt} onChange={handleFieldValueChange} className="flex flex-col">
<form action={loginAction} onChange={handleFieldValueChange} className="flex flex-col">
<div className="flex flex-col gap-16">
<InputField
type="text"
Expand Down
18 changes: 3 additions & 15 deletions src/components/form/post.tsx
Original file line number Diff line number Diff line change
@@ -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 { CommunityView } from 'sublinks-js-client';
import { Spinner } from '@material-tailwind/react';
Expand All @@ -13,7 +11,6 @@ import { Selector } from '@/components/input/select';
import { BodyTitleInverse, ErrorText, PaleBodyText } from '@/components/text';
import SublinksApi from '@/utils/api-client/client';
import logger from '@/utils/logger';
import { UserContext } from '@/context/user';
import { isImage } from '@/utils/links';
import { getCommunitySlugFromUrl } from '@/utils/communities';

Expand All @@ -38,18 +35,11 @@ const REQUIRED_FIELDS = [

const PostForm = ({ communities }: PostFormProps) => {
const router = useRouter();
const { userData } = useContext(UserContext);
const [erroneousFields, setErroneousFields] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string>('');
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<string, string | number | File>) => {
const emptyFields: string[] = [];

Expand Down Expand Up @@ -77,13 +67,11 @@ const PostForm = ({ communities }: PostFormProps) => {
return undefined;
};

const handleCreationAttempt = async (event: FormEvent<HTMLFormElement>) => {
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,
Expand Down Expand Up @@ -152,7 +140,7 @@ const PostForm = ({ communities }: PostFormProps) => {
}));

return (
<form onSubmit={handleCreationAttempt} onChange={handleFieldValueChange} className="flex flex-col">
<form action={postCreateAction} onChange={handleFieldValueChange} className="flex flex-col">
<div className="flex flex-col gap-16">
<Selector
id={INPUT_IDS.COMMUNITY}
Expand Down
6 changes: 2 additions & 4 deletions src/components/form/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,11 @@ const SignupForm = () => {
}
}, [userData]); // eslint-disable-line react-hooks/exhaustive-deps

const handleSignupAttempt = async (event: FormEvent<HTMLFormElement>) => {
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,
Expand Down Expand Up @@ -113,7 +111,7 @@ const SignupForm = () => {
};

return (
<form onSubmit={handleSignupAttempt} onChange={handleFieldValueChange} className="flex flex-col">
<form action={signUpAction} onChange={handleFieldValueChange} className="flex flex-col">
<div className="flex flex-col gap-16">
<InputField
type="email"
Expand Down
4 changes: 2 additions & 2 deletions src/utils/api-client/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetch as crossFetch } from 'cross-fetch';
import crossFetch from 'cross-fetch';
import { SublinksClient } from 'sublinks-js-client';

import logger from '../logger';
Expand Down Expand Up @@ -37,7 +37,7 @@ class SublinksApiBase {

constructor() {
this.rawClient = new SublinksClient(getApiHost(), {
fetchFunction: crossFetch,
fetchFunction: crossFetch.bind(globalThis),
insecure: process.env.NEXT_PUBLIC_HTTPS_ENABLED !== 'true'
});
this.client = this.getWrappedClient();
Expand Down
5 changes: 5 additions & 0 deletions src/utils/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use server';

import { revalidatePath } from 'next/cache';

export const revalidateAll = () => revalidatePath('/', 'layout');

0 comments on commit ce3574b

Please sign in to comment.