Skip to content

Commit

Permalink
Feat/admin (#81)
Browse files Browse the repository at this point in the history
* chore: admin login wip

* chore: admin login complete

* chore: sample env updated

* v1.4.0

* fix: types updated
  • Loading branch information
kunalkeshan authored Mar 1, 2023
1 parent 88b70d6 commit d6f7842
Show file tree
Hide file tree
Showing 12 changed files with 516 additions and 42 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ADMIN_PASSWORD=""
IRON_SESSION_COOKIE_PASSWORD=""
4 changes: 4 additions & 0 deletions @types/custom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type Required<T, K extends keyof T> = T & { [P in K]-?: T[P] };

type WithRequired<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Required<T, K>;
11 changes: 8 additions & 3 deletions @types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
type Required<T, K extends keyof T> = T & { [P in K]-?: T[P] };
import * as IronSession from "iron-session";

type WithRequired<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Required<T, K>;
declare module "iron-session" {
interface IronSessionData {
user?: {
admin?: boolean;
};
}
}
10 changes: 10 additions & 0 deletions config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

// Dependencies
import { IronSessionOptions } from "iron-session";

export const isProduction = process.env.NODE_ENV === "production";

Expand All @@ -12,3 +13,12 @@ export const IMAGE_SOURCE = {
ART_IMAGE:
"https://res.cloudinary.com/kunalkeshan/image/upload/v1675069234/Portfolio/art-pic-kunal-keshan.jpg",
};

export const ironOptions: IronSessionOptions = {
cookieName: "kunalkeshan.dev_admin_auth_cookie",
password: process.env.IRON_SESSION_COOKIE_PASSWORD!,
...(isProduction && { secure: true }),
cookieOptions: {
secure: isProduction,
},
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kunalkeshan-dev",
"version": "1.3.1",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand All @@ -18,14 +18,16 @@
"eslint": "8.28.0",
"eslint-config-next": "13.0.5",
"framer-motion": "^8.5.5",
"iron-session": "^6.3.1",
"next": "13.0.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-headroom": "^3.2.1",
"react-icons": "^4.7.1",
"react-lottie-player": "^1.5.0",
"typescript": "4.9.3",
"typewriter-effect": "^2.19.0"
"typewriter-effect": "^2.19.0",
"zod": "^3.20.6"
},
"devDependencies": {
"@types/gtag.js": "^0.0.12",
Expand Down
32 changes: 0 additions & 32 deletions pages/admin/auth.tsx

This file was deleted.

24 changes: 24 additions & 0 deletions pages/admin/home/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Admin Home
*/

// Dependencies
import React from "react";
import Head from "next/head";
import PublicLayout from "../../../layouts/PublicLayout";
import WorkInProgress from "../../../components/reusable/WorkInProgress";

const AdminHomePage = () => {
return (
<>
<Head>
<title>Admin | Kunal Keshan</title>
</Head>
<PublicLayout>
<WorkInProgress />
</PublicLayout>
</>
);
};

export default AdminHomePage;
135 changes: 131 additions & 4 deletions pages/admin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,80 @@
*/

// Dependencies
import React from "react";
import React, { useState } from "react";
import Head from "next/head";
import PublicLayout from "../../layouts/PublicLayout";
import WorkInProgress from "../../components/reusable/WorkInProgress";
import { motion } from "framer-motion";
import { AiFillLock } from "react-icons/ai";
import axios, { AxiosError } from "axios";
import { withSessionSsr } from "../../utils/withSession";

const AdminPage = () => {
const [input, setInput] = useState("");
const [error, setError] = useState({
error: false,
message: "",
});
const [request, setRequest] = useState({
loading: false,
success: false,
});

const handleError = (message: string) => {
setError({
error: true,
message,
});
};

const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
setError({
error: false,
message: "",
});
};

const handleAdminLogin = async () => {
if (input.length === 0) {
handleError("Password is a required to login!");
} else if (input !== process.env.ADMIN_PASSWORD) {
try {
setRequest((prev) => {
return { ...prev, loading: true };
});
const response = await axios.post("/api/admin/login", {
password: input,
});
if (response.data.ok) {
setRequest((prev) => {
return { ...prev, success: true };
});
}
} catch (error) {
setRequest((prev) => {
return { ...prev, success: false };
});
if (error instanceof AxiosError) {
switch (error.response?.data.message) {
case "admin/wrong-password": {
handleError("Wrong password. Try again!");
break;
}
default: {
handleError("Something went wrong. Try again!");
break;
}
}
}
} finally {
setRequest((prev) => {
return { ...prev, loading: false };
});
}
}
};

return (
<>
<Head>
Expand All @@ -20,13 +87,73 @@ const AdminPage = () => {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, type: "spring" }}
className="mx-auto mt-10 mb-20 max-w-7xl px-5"
className="mx-auto mt-10 mb-20 flex max-w-7xl items-center justify-center px-5"
>
<WorkInProgress />
<div className="flex flex-col items-center justify-center gap-6 rounded-xl border-3 border-black px-5 py-10 lg:min-w-[32rem]">
<div className="group rounded-full border-2 border-black p-4 text-7xl shadow-3d-small transition-all duration-300 hover:-translate-y-1 hover:shadow-3d">
<AiFillLock className="transition-all duration-300 group-hover:scale-105" />
</div>
<h1 className="text-3xl font-bold md:text-5xl">Admin Login</h1>
<p className="text-center font-montserrat text-base md:text-lg">
{request.success
? "Login in Successful! Redirecting to home page now..."
: "Enter your password to access the admin dashboard."}
</p>
<div className="w-full">
<input
type="text"
className={`${
error.error
? "border-red-500 placeholder:text-red-500"
: "border-black hover:shadow-3d focus:shadow-3d"
} mt-2 w-full rounded-xl border-2 p-4 outline-none transition-all duration-300`}
placeholder="Password"
name="password"
value={input}
onChange={handleInput}
autoComplete="off"
/>
{error.error && (
<p className="ml-2 mt-1 text-sm text-red-500">
{error.message}
</p>
)}
</div>
<button
disabled={request.success}
onClick={handleAdminLogin}
className={`${
request.success
? "bg-green-500 bg-opacity-80"
: "bg-black hover:-translate-y-1 hover:bg-portfolio-accent"
} w-full rounded-xl px-8 py-4 text-center text-lg font-semibold text-white transition-all duration-300`}
>
{request.loading ? "Logging in..." : "Login"}
</button>
</div>
</motion.section>
</PublicLayout>
</>
);
};

export default AdminPage;

export const getServerSideProps = withSessionSsr(
async function getServerSideProps({ req }) {
const user = req.session.user;

if (user?.admin) {
return {
redirect: {
destination: "/admin/home",
permanent: true,
},
};
}

return {
props: {},
};
}
);
51 changes: 51 additions & 0 deletions pages/api/admin/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { withSessionRoute } from "../../../utils/withSession";
import { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import ApiError from "../../../utils/apiError";

export default withSessionRoute(
async (req: NextApiRequest, res: NextApiResponse) => {
switch (req.method) {
case "POST": {
return await loginRoute(req, res);
}
}
}
);

const loginBodySchema = z.string();

interface LoginAdminApiRequest extends NextApiRequest {
body: {
password: z.infer<typeof loginBodySchema>;
};
}

async function loginRoute(req: LoginAdminApiRequest, res: NextApiResponse) {
try {
const { password } = req.body;
const valid = loginBodySchema.safeParse(password);
if (!valid.success)
throw new ApiError({
statusCode: 400,
message: "admin/invalid-password-type",
});
const validPassword = password! === process.env.ADMIN_PASSWORD!;
if (!validPassword)
throw new ApiError({
statusCode: 401,
message: "admin/wrong-password",
});
req.session = { ...req.session, user: { admin: true } };
await req.session.save();
return res.status(200).json({ ok: true });
} catch (error) {
if (error instanceof ApiError) {
return res
.status(error.statusCode)
.json({ message: error.message, data: error.data });
} else {
return res.status(500).json({ message: "app/internal-server-error" });
}
}
}
20 changes: 20 additions & 0 deletions utils/apiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
interface ApiErrorOptions {
message: string;
statusCode: number;
data?: object;
}

class ApiError extends Error {
public statusCode: number;
public data: object | undefined;
constructor({ message, statusCode, data }: ApiErrorOptions) {
super();
this.message = message;
this.statusCode = statusCode;
this.data = data;

// Helps identify `instanceOf` applications
Object.setPrototypeOf(this, ApiError.prototype);
}
}
export default ApiError;
24 changes: 24 additions & 0 deletions utils/withSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
GetServerSidePropsContext,
GetServerSidePropsResult,
NextApiHandler,
} from "next";
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import { ironOptions } from "../config";

function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, ironOptions);
}

// Theses types are compatible with InferGetStaticPropsType https://nextjs.org/docs/basic-features/data-fetching#typescript-use-getstaticprops
function withSessionSsr<
P extends { [key: string]: unknown } = { [key: string]: unknown }
>(
handler: (
context: GetServerSidePropsContext
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>
) {
return withIronSessionSsr(handler, ironOptions);
}

export { withSessionRoute, withSessionSsr };
Loading

1 comment on commit d6f7842

@vercel
Copy link

@vercel vercel bot commented on d6f7842 Mar 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.