From 52d4c35c2e41c3e17aaec720c29e77b61c4afb11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Dere=C5=BEi=C4=87?= Date: Sat, 6 Jan 2024 10:50:27 +0100 Subject: [PATCH] implemented login --- .env | 1 + .gitignore | 1 - README.md | 15 ++++++- app/lib/billActions.ts | 2 +- app/lib/global.d.ts | 12 +++--- app/lib/loginActions.ts | 21 ++++++++++ app/lib/sql-shim.ts | 25 ----------- app/lib/types/User.ts | 6 +++ app/login/page.tsx | 11 +++++ app/ui/LoginForm.tsx | 91 +++++++++++++++++++++++++++++++++++++++++ auth.config.ts | 24 +++++++++++ auth.ts | 67 ++++++++++++++++++++++++++++++ middleware.ts | 14 +++++++ package-lock.json | 2 +- 14 files changed, 257 insertions(+), 35 deletions(-) create mode 100644 .env create mode 100644 app/lib/loginActions.ts delete mode 100644 app/lib/sql-shim.ts create mode 100644 app/lib/types/User.ts create mode 100644 app/login/page.tsx create mode 100644 app/ui/LoginForm.tsx create mode 100644 auth.config.ts create mode 100644 auth.ts create mode 100644 middleware.ts diff --git a/.env b/.env new file mode 100644 index 0000000..43efb38 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +AUTH_SECRET=Gh0jQ35oq6DR8HkLR3heA8EaEDtxYN/xkP6blvukZ0w= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8ac3a8f..dbc1565 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,6 @@ yarn-error.log* # local env files .env*.local -.env # vercel .vercel diff --git a/README.md b/README.md index f709e49..21b55cb 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,17 @@ * multi-user support * bill amount entry * monthly bill amount summery -* build & deploy via docker \ No newline at end of file +* build & deploy via docker + + +# Authentication +Authentication consists of the following parts: +* `next-auth` boilerplate + * `middleware.ts` = hooks-up `next-auth` into the page processing pipeline + * `auth.config.ts` = defines how user session is to be checked and redirects anonymous user to login page + * `auth.ts` = verifies user credentials during the log-in action (i.e. against a database) + * exports `auth`, `signIn`, `signOut` actions +* UI boilerplate + * `sidenav.tsx` = implements logout action - calls `signOut` from `auth.ts` + * `login-form.tsx` = implements login form + * `actions.ts` = handles login-form validation and submition - calls `signIn` from `auth.ts` diff --git a/app/lib/billActions.ts b/app/lib/billActions.ts index c66add7..fff0572 100644 --- a/app/lib/billActions.ts +++ b/app/lib/billActions.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import clientPromise from './mongodb'; -import { BillAttachment, Bill, BillingLocation } from './db-types'; +import { BillAttachment, BillingLocation } from './db-types'; import { ObjectId } from 'mongodb'; export type State = { diff --git a/app/lib/global.d.ts b/app/lib/global.d.ts index 52c4786..9e9c43b 100644 --- a/app/lib/global.d.ts +++ b/app/lib/global.d.ts @@ -1,8 +1,8 @@ import { MongoClient } from "mongodb"; -declare global { - namespace globalThis { - /** global Mongo Client used in development */ - var _mongoClientPromise: Promise - } -} \ No newline at end of file +// declare global { +// namespace globalThis { +// /** global Mongo Client used in development */ +// var _mongoClientPromise: Promise +// } +// } \ No newline at end of file diff --git a/app/lib/loginActions.ts b/app/lib/loginActions.ts new file mode 100644 index 0000000..d6396aa --- /dev/null +++ b/app/lib/loginActions.ts @@ -0,0 +1,21 @@ +import { signIn } from '@/auth'; +import { AuthError } from 'next-auth'; + +export async function authenticate( + prevState: string | undefined, + formData: FormData, + ) { + try { + await signIn('credentials', formData); + } catch (error) { + if (error instanceof AuthError) { + switch (error.type) { + case 'CredentialsSignin': + return 'Invalid credentials.'; + default: + return 'Something went wrong.'; + } + } + throw error; + } + } \ No newline at end of file diff --git a/app/lib/sql-shim.ts b/app/lib/sql-shim.ts deleted file mode 100644 index 0ec3839..0000000 --- a/app/lib/sql-shim.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Client, QueryResult, QueryResultRow } from 'pg'; - -const client = new Client({ - // connectionString: process.env.DATABASE_URL, - host: process.env.POSTGRES_HOST, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, - database: process.env.POSTGRES_DB - }); - -client.connect(); - -/** an adapter function which simulates @vercel/postgres `sql` function */ -export function sql(strings: TemplateStringsArray, ...values: any[]): Promise> { - // string values need to be wrapped in single quotes - const fixedValues = values.map((value) => { - if (typeof value === 'string') { - return `'${value}'`; - } - return value; - }); - - const query = String.raw(strings, ...fixedValues); - return client.query(query); - } \ No newline at end of file diff --git a/app/lib/types/User.ts b/app/lib/types/User.ts new file mode 100644 index 0000000..0fbd6c8 --- /dev/null +++ b/app/lib/types/User.ts @@ -0,0 +1,6 @@ +export type User = { + id: string; + name: string; + email: string; + password: string; + }; \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..eaa8df7 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,11 @@ +import LoginForm from '@/app/ui/LoginForm'; + +export default function LoginPage() { + return ( +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/ui/LoginForm.tsx b/app/ui/LoginForm.tsx new file mode 100644 index 0000000..299f3d8 --- /dev/null +++ b/app/ui/LoginForm.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { lusitana } from '@/app/ui/fonts'; +import { + AtSymbolIcon, + KeyIcon, + ExclamationCircleIcon, +} from '@heroicons/react/24/outline'; +import { ArrowRightIcon } from '@heroicons/react/20/solid'; +import { Button } from './button'; +import { useFormState } from 'react-dom'; +import { authenticate } from '@/app/lib/loginActions'; + +export default function LoginForm() { + + const [errorMessage, dispatch] = useFormState(authenticate, undefined); + + return ( +
+
+

+ Please log in to continue. +

+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ {errorMessage && ( + <> + +

{errorMessage}

+ + )} +
+
+
+
+ ); +} + +function LoginButton() { + return ( + + ); +} diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..23d5d94 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,24 @@ +/** + * @module auth.config.ts + * @description defines how user session is to be checked and redirects anonymous user to login page + */ +import type { NextAuthConfig } from 'next-auth'; + +export const authConfig = { + pages: { + signIn: '/login', + }, + // this will prevent users from accessing the dashboard pages unless they are logged in + callbacks: { + // The authorized callback is used to verify if the request is authorized to access a + // page via Next.js Middleware. It is called before a request is completed, and it + // receives an object with the auth and request properties. + // The auth property contains the user's session, and the request property contains + // the incoming request. + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + return(isLoggedIn); + }, + }, + providers: [], // Add providers with an empty array for now +} satisfies NextAuthConfig; \ No newline at end of file diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..d2f57c7 --- /dev/null +++ b/auth.ts @@ -0,0 +1,67 @@ +/** + * @module auth + * @description verifies user credentials during the log-in action (i.e. against a database) + * @exports exports `auth`, `signIn`, `signOut` actions + */ +import NextAuth from 'next-auth'; +import { authConfig } from './auth.config'; +import Credentials from 'next-auth/providers/credentials'; +import { z } from 'zod'; +// import bcrypt from 'bcrypt'; +import { User } from '@/app/lib/types/User'; + +const dummyUser:User = { + id: "1", + email: "nikola.derezic@gmail.com", + password: "123456", + name: "Nikola Derezic" +}; + +async function getUser(email: string): Promise { + // temporary use dummyUser instead of db + if(email === dummyUser.email) { + return dummyUser; + } + + return undefined; + + // try { + // const user = await sql`SELECT * FROM users WHERE email=${email}`; + // return user.rows[0]; + // } catch (error) { + // console.error('Failed to fetch user:', error); + // throw new Error('Failed to fetch user.'); + // } + } + +export const { auth, signIn, signOut } = NextAuth({ + ...authConfig, + + providers: [ + Credentials({ + async authorize(credentials) { + const parsedCredentials = z.object({ + email: z.string().email(), + password: z.string().min(6) + }).safeParse(credentials); + + if (!parsedCredentials.success) { + return null; + } + + const { email, password } = parsedCredentials.data; + + const user = await getUser(email); + + if (!user) return null; + + // const passwordsMatch = await bcrypt.compare(password, user.password); + const passwordsMatch = password === user.password; + + if (!passwordsMatch) return null; + + return user; + } + }) + ], +}); \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..9fb76f6 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,14 @@ +/** + * @module middleware + * @description hooks-up `next-auth` into the page processing pipeline + */ + +import NextAuth from 'next-auth'; +import { authConfig } from './auth.config'; + +export default NextAuth(authConfig).auth; + +export const config = { + // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher + matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 366b564..4967c0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "rezije", + "name": "evidencija-rezija", "lockfileVersion": 3, "requires": true, "packages": {