Implemented a simple authentication scheme with Firebase and Iron Session

This commit is contained in:
Tiago Ribeiro
2023-04-12 16:53:36 +01:00
parent cb1a67de23
commit 58bdc745e4
16 changed files with 1371 additions and 33 deletions

View File

@@ -16,21 +16,28 @@
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"chart.js": "^4.2.1",
"clsx": "^1.2.1",
"daisyui": "^2.50.0",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"firebase": "9.19.1",
"framer-motion": "^9.0.2",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"next": "13.1.6",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-lineto": "^3.3.0",
"react-player": "^2.12.0",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"swr": "^2.1.3",
"typescript": "4.9.5",
"zustand": "^4.3.6"
},

View File

@@ -1,4 +1,6 @@
import axios from "axios";
import Link from "next/link";
import {useRouter} from "next/router";
interface Props {
profilePicture: string;
@@ -6,8 +8,16 @@ interface Props {
/* eslint-disable @next/next/no-img-element */
export default function Navbar({profilePicture}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
router.push("/login");
});
};
return (
<div className="navbar bg-neutral-100 drop-shadow-md text-black">
<div className="navbar bg-neutral-100 drop-shadow-md text-black z-10">
<div className="flex-1">
<Link className="btn btn-ghost normal-case text-xl" href="/">
IELTS GPT
@@ -34,7 +44,7 @@ export default function Navbar({profilePicture}: Props) {
<a>Settings</a>
</li>
<li>
<a>Logout</a>
<a onClick={logout}>Logout</a>
</li>
</ul>
</div>

View File

@@ -1,5 +1,5 @@
{
"username": "tiago.ribeiro",
"email": "tiago.ribeiro@ecrop.dev",
"name": {
"first": "Tiago",
"last": "Ribeiro"

14
src/firebase/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import {initializeApp} from "firebase/app";
import {getFirestore} from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
authDomain: process.env.FIREBASE_AUTH_DOMAIN || "",
projectId: process.env.FIREBASE_PROJECT_ID || "",
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || "",
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || "",
appId: process.env.FIREBASE_APP_ID || "",
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
};
export const app = initializeApp(firebaseConfig);

29
src/hooks/useUser.tsx Normal file
View File

@@ -0,0 +1,29 @@
import {useEffect} from "react";
import Router from "next/router";
import useSWR from "swr";
import {User} from "@/interfaces/user";
import axios from "axios";
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export default function useUser({redirectTo = "", redirectIfFound = false} = {}) {
const {data: user, mutate: mutateUser, isLoading} = useSWR<User>("/api/user", fetcher);
useEffect(() => {
// if no redirect needed, just return (example: already on /dashboard)
// if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
console.log(redirectTo, user);
if (!redirectTo || !user) return;
if (
// If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && !user) ||
// If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user)
) {
Router.push(redirectTo);
}
}, [user, redirectIfFound, redirectTo]);
return {user, mutateUser, isLoading};
}

View File

@@ -1,5 +1,5 @@
export interface User {
username: string;
email: string;
name: Name;
profilePicture: string;
id: string;

18
src/lib/session.ts Normal file
View File

@@ -0,0 +1,18 @@
// this file is a wrapper with defaults to be used in both API routes and `getServerSideProps` functions
import type {IronSessionOptions} from "iron-session";
import {User} from "@/interfaces/user";
export const sessionOptions: IronSessionOptions = {
password: process.env.SECRET_COOKIE_PASSWORD as string,
cookieName: "eCrop/ielts",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};
// This is where we specify the typings of req.session.*
declare module "iron-session" {
interface IronSessionData {
user?: User | null;
}
}

View File

@@ -2,6 +2,10 @@ import "@/styles/globals.css";
import "react-toastify/dist/ReactToastify.css";
import type {AppProps} from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
export default function App({Component, pageProps}: AppProps) {
return <Component {...pageProps} />;
}

View File

@@ -0,0 +1,19 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, collection, getDocs} from "firebase/firestore";
const db = getFirestore(app);
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const {module} = req.query as {module: string};
const snapshot = await getDocs(collection(db, module));
res.status(200).json(
snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})),
);
}

38
src/pages/api/login.ts Normal file
View File

@@ -0,0 +1,38 @@
import {NextApiRequest, NextApiResponse} from "next";
import {getAuth, signInWithEmailAndPassword} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
import {User} from "@/interfaces/user";
import {getFirestore, getDoc, doc} from "firebase/firestore";
const auth = getAuth(app);
const db = getFirestore(app);
export default withIronSessionApiRoute(login, sessionOptions);
async function login(req: NextApiRequest, res: NextApiResponse) {
const {email, password} = req.body as {email: string; password: string};
signInWithEmailAndPassword(auth, email, password)
.then(async (userCredentials) => {
const userId = userCredentials.user.uid;
const docUser = await getDoc(doc(db, "users", userId));
if (!docUser.exists()) {
res.status(401).json({error: 401, message: "User does not exist!"});
return;
}
const user = docUser.data() as User;
req.session.user = user;
await req.session.save();
res.status(200).json({user: {...user, id: userId}});
})
.catch((error) => {
console.log(error);
res.status(401).json({error});
});
}

21
src/pages/api/logout.ts Normal file
View File

@@ -0,0 +1,21 @@
import {NextApiRequest, NextApiResponse} from "next";
import {getAuth, signOut} from "firebase/auth";
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {withIronSessionApiRoute} from "iron-session/next";
const auth = getAuth(app);
export default withIronSessionApiRoute(logout, sessionOptions);
async function logout(req: NextApiRequest, res: NextApiResponse) {
signOut(auth)
.then(() => {
req.session.destroy();
res.status(200).json({ok: true});
})
.catch(() => {
req.session.destroy();
res.status(500).json({ok: false});
});
}

27
src/pages/api/user.ts Normal file
View File

@@ -0,0 +1,27 @@
import {app} from "@/firebase";
import {sessionOptions} from "@/lib/session";
import {getAuth} from "firebase/auth";
import {withIronSessionApiRoute} from "iron-session/next";
import {NextApiRequest, NextApiResponse} from "next";
const auth = getAuth(app);
export default withIronSessionApiRoute(user, sessionOptions);
async function user(req: NextApiRequest, res: NextApiResponse) {
if (req.session.user) {
console.log(auth.currentUser);
if (!auth.currentUser) {
res.status(401).json({ok: false});
return;
}
if (req.session.user.id === auth.currentUser.uid) {
res.status(401).json({ok: false});
return;
}
res.json({user: req.session.user});
} else {
res.status(401).json({ok: false});
}
}

View File

@@ -4,22 +4,38 @@ import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
// TODO: Remove this import
import JSON_USER from "@/demo/user.json";
import JSON_READING from "@/demo/reading.json";
import JSON_LISTENING from "@/demo/listening.json";
import JSON_WRITING from "@/demo/writing.json";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, ListeningExam, ReadingExam, UserSolution, WritingExam} from "@/interfaces/exam";
import Listening from "@/exams/Listening";
import Writing from "@/exams/Writing";
import {ToastContainer} from "react-toastify";
import Link from "next/link";
import {ToastContainer, toast} from "react-toastify";
import Finish from "@/exams/Finish";
import axios from "axios";
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
export default function Home() {
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Page({user}: {user: User}) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [moduleIndex, setModuleIndex] = useState(0);
const [exam, setExam] = useState<Exam>();
@@ -27,21 +43,31 @@ export default function Home() {
const [showSolutions, setShowSolutions] = useState(false);
useEffect(() => {
(async () => {
if (selectedModules.length > 0 && moduleIndex < selectedModules.length) {
const nextExam = getExam(selectedModules[moduleIndex]);
const nextExam = await getExam(selectedModules[moduleIndex]);
setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, moduleIndex]);
const getExam = (module: Module): Exam | undefined => {
const getExam = async (module: Module): Promise<Exam | undefined> => {
const examRequest = await axios<Exam[]>(`/api/exam/${module}`);
if (examRequest.status !== 200) {
toast.error("Something went wrong!");
return undefined;
}
const newExam = examRequest.data;
switch (module) {
case "reading":
return JSON_READING as ReadingExam;
return newExam.shift() as ReadingExam;
case "listening":
return JSON_LISTENING as ListeningExam;
return newExam.shift() as ListeningExam;
case "writing":
return JSON_WRITING as WritingExam;
return newExam.shift() as WritingExam;
}
return undefined;
@@ -63,13 +89,13 @@ export default function Home() {
const renderScreen = () => {
if (selectedModules.length === 0) {
return <Selection user={JSON_USER} onStart={setSelectedModules} />;
return <Selection user={user} onStart={setSelectedModules} />;
}
if (moduleIndex >= selectedModules.length) {
return (
<Finish
user={JSON_USER}
user={user}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
@@ -110,7 +136,7 @@ export default function Home() {
</Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100 text-black">
<ToastContainer />
<Navbar profilePicture={JSON_USER.profilePicture} />
<Navbar profilePicture={user.profilePicture} />
{renderScreen()}
</main>
</>

View File

@@ -11,9 +11,31 @@ import ProfileCard from "@/components/ProfileCard";
// TODO: Remove this import
import JSON_RESULTS from "@/demo/user_results.json";
import JSON_USER from "@/demo/user.json";
export default function Home() {
import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {User} from "@/interfaces/user";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user;
if (!user) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions);
export default function Home({user}: {user: User}) {
return (
<>
<Head>
@@ -26,7 +48,7 @@ export default function Home() {
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-screen flex flex-col items-center bg-neutral-100">
<Navbar profilePicture={JSON_USER.profilePicture} />
<Navbar profilePicture={user.profilePicture} />
<div className="w-full h-full p-4 relative">
<Link href="/exam">
<button className={clsx("btn gap-2 top-12 right-12 absolute", infoButtonStyle)}>
@@ -36,7 +58,7 @@ export default function Home() {
</Link>
<section className="h-full w-full flex items-center p-8 gap-12 justify-center">
<section className="w-1/2 h-full flex items-center">
<ProfileCard user={JSON_USER} className="text-black self-start" />
<ProfileCard user={user} className="text-black self-start" />
</section>
<section className="w-1/2 h-full flex items-center justify-center">
<UserResultChart results={JSON_RESULTS} resultKey="total" label="Total exams" className="w-2/3" />

82
src/pages/login.tsx Normal file
View File

@@ -0,0 +1,82 @@
import {User} from "@/interfaces/user";
import {toast, ToastContainer} from "react-toastify";
import axios from "axios";
import {FormEvent, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {InputText} from "primereact/inputtext";
import {Button} from "primereact/button";
export default function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const {mutateUser} = useUser({
redirectTo: "/",
redirectIfFound: true,
});
const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
axios
.post<User>("/api/login", {email, password})
.then((response) => {
toast.success("You have been logged in!", {toastId: "login-successful"});
mutateUser(response.data);
})
.catch((e) => {
if (e.response.status === 401) {
toast.error("Wrong login credentials!", {toastId: "wrong-credentials"});
} else {
toast.error("Something went wrong!", {toastId: "server-error"});
}
setIsLoading(false);
});
};
return (
<>
<Head>
<title>Login | IELTS GPT</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-screen flex flex-col items-center justify-center bg-neutral-100">
<ToastContainer />
<form className="p-4 rounded-xl bg-white drop-shadow-xl flex flex-col gap-4" onSubmit={login}>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-inbox"></i>
</span>
<InputText
placeholder="E-mail..."
type="email"
required
disabled={isLoading}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>
</div>
<div className="p-inputgroup">
<span className="p-inputgroup-addon">
<i className="pi pi-star"></i>
</span>
<InputText
placeholder="Password..."
type="password"
required
disabled={isLoading}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<Button loading={isLoading} iconPos="right" label="Login" icon="pi pi-check" />
</form>
</main>
</>
);
}

1031
yarn.lock

File diff suppressed because it is too large Load Diff