diff --git a/app/lib/actions.ts b/app/lib/actions.ts index a571cf0..a99a225 100644 --- a/app/lib/actions.ts +++ b/app/lib/actions.ts @@ -1,6 +1,8 @@ 'use server'; +import { signIn } from '@/auth'; import { sql } from '@vercel/postgres'; +import { AuthError } from 'next-auth'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { z } from 'zod'; @@ -75,17 +77,17 @@ export async function updateInvoice( amount: formData.get('amount'), status: formData.get('status'), }); - + if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Missing Fields. Failed to Update Invoice.', }; } - + const { customerId, amount, status } = validatedFields.data; const amountInCents = amount * 100; - + try { await sql` UPDATE invoices @@ -95,7 +97,7 @@ export async function updateInvoice( } catch (error) { return { message: 'Database Error: Failed to Update Invoice.' }; } - + revalidatePath('/dashboard/invoices'); redirect('/dashboard/invoices'); } @@ -112,3 +114,21 @@ export async function deleteInvoice(id: string) { } } +export async function authenticate( + prevSate: 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; + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..805055c --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,17 @@ +import AcmeLogo from '@/app/ui/acme-logo'; +import LoginForm from '@/app/ui/login-form'; + +export default function LoginPage() { + return ( +
+
+
+
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/app/ui/dashboard/sidenav.tsx b/app/ui/dashboard/sidenav.tsx index 3d55b46..0ce9302 100644 --- a/app/ui/dashboard/sidenav.tsx +++ b/app/ui/dashboard/sidenav.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import NavLinks from '@/app/ui/dashboard/nav-links'; import AcmeLogo from '@/app/ui/acme-logo'; import { PowerIcon } from '@heroicons/react/24/outline'; +import { signOut } from '@/auth'; export default function SideNav() { return ( @@ -17,7 +18,12 @@ export default function SideNav() {
-
+ { + 'use server'; + await signOut(); + }} + >
-
- {/* Add form errors here */} +
+ {errorMessage && ( + <> + +

{errorMessage}

+ + )}
@@ -65,8 +79,9 @@ export default function LoginForm() { } function LoginButton() { + const { pending } = useFormStatus(); return ( - ); diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..b480147 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,21 @@ +import type { NextAuthConfig } from 'next-auth'; + +export const authConfig = { + pages: { + signIn: '/login', + }, + callbacks: { + authorized({ auth, request: { nextUrl } }) { + const isLoggedIn = !!auth?.user; + const isOnDashboard = nextUrl.pathname.startsWith('/dashboard'); + if (isOnDashboard) { + if (isLoggedIn) return true; + return false; // Redirect unauthenticated users to login page + } else if (isLoggedIn) { + return Response.redirect(new URL('/dashboard', nextUrl)); + } + return true; + }, + }, + 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..62c9e93 --- /dev/null +++ b/auth.ts @@ -0,0 +1,42 @@ +import NextAuth from 'next-auth'; +import { authConfig } from './auth.config'; +import Credentials from 'next-auth/providers/credentials'; +import { z } from 'zod'; +import { sql } from '@vercel/postgres'; +import type { User } from '@/app/lib/definitions'; +import bcrypt from 'bcrypt'; + +async function getUser(email: string): Promise { + 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) { + const { email, password } = parsedCredentials.data; + const user = await getUser(email); + if (!user) return null; + const passwordMatch = await bcrypt.compare(password, user.password); + + if (passwordMatch) return user; + } + + console.log('Invalid credentials'); + return null; + }, + }), + ], +}); \ No newline at end of file diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..1fdda5b --- /dev/null +++ b/middleware.ts @@ -0,0 +1,9 @@ +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 aca7802..8bec0f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "bcrypt": "^5.1.1", "clsx": "^2.0.0", "next": "^14.1.0", + "next-auth": "^5.0.0-beta.5", "postcss": "8.4.31", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -70,6 +71,28 @@ "node": ">=6.0.0" } }, + "node_modules/@auth/core": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.21.0.tgz", + "integrity": "sha512-jUWYs8gjy2GvtP9dd/4S9KcwZ660Cm/IkybiAq96/2Ooku9SKk5SUG+UTEwkyLuaQ38ZgfwggfpDOgzsXEcufA==", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -1103,6 +1126,14 @@ "node": ">= 8" } }, + "node_modules/@panva/hkdf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", + "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1167,6 +1198,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/json-schema": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", @@ -2374,6 +2410,14 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4806,6 +4850,14 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jose": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.0.tgz", + "integrity": "sha512-oW3PCnvyrcm1HMvGTzqjxxfnEs9EoFOFWi2HsEGhlFVOXxTE3K9GKWVMFoFw06yPUqwpvEWic1BmtUZBI/tIjw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5207,6 +5259,24 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.5", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.5.tgz", + "integrity": "sha512-tEYIPhm/i0byW4xf9Ldlsnh7ckBQfNvMjOA3B3eXQX9taAQWb8earravSn1fjk4fKfKvaOApFJkks0VeI7Vy/A==", + "dependencies": { + "@auth/core": "0.21.0" + }, + "peerDependencies": { + "next": "^14", + "nodemailer": "^6.6.5", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + } + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -5335,6 +5405,14 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth4webapi": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.8.1.tgz", + "integrity": "sha512-Jm1Z6eUumtevQWxMllSw+4diHOcFyxuc3KAXoyh4fbpHndbXRbviyrLoCn8htEdHYZM/MIOVbeWjDk86BxVF+A==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5880,6 +5958,26 @@ "node": ">=0.10.0" } }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5994,6 +6092,11 @@ } } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index 3e4eade..f310981 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "bcrypt": "^5.1.1", "clsx": "^2.0.0", "next": "^14.1.0", + "next-auth": "^5.0.0-beta.5", "postcss": "8.4.31", "react": "^18.2.0", "react-dom": "^18.2.0",