From e1f9515341c0ee27c1d4fb41c22788a53a00c9be Mon Sep 17 00:00:00 2001 From: Toastie Date: Fri, 19 Apr 2024 16:09:08 +1200 Subject: [PATCH] Finished this project --- app/dashboard/(overview)/page.tsx | 5 + app/dashboard/customers/page.tsx | 6 ++ app/dashboard/invoices/[id]/edit/page.tsx | 6 ++ app/dashboard/invoices/create/page.tsx | 5 + app/dashboard/invoices/page.tsx | 5 + {public => app}/favicon.ico | Bin app/layout.tsx | 10 ++ app/lib/actions.ts | 21 ++++ app/login/page.tsx | 22 ++++ {public => app}/opengraph-image.png | Bin app/ui/dashboard/sidenav.tsx | 7 +- app/ui/login-form.tsx | 24 ++++- auth.config.ts | 21 ++++ auth.ts | 42 ++++++++ middleware.ts | 9 ++ package-lock.json | 119 ++++++++++++++++++++++ package.json | 1 + 17 files changed, 298 insertions(+), 5 deletions(-) rename {public => app}/favicon.ico (100%) create mode 100644 app/login/page.tsx rename {public => app}/opengraph-image.png (100%) create mode 100644 auth.config.ts create mode 100644 auth.ts create mode 100644 middleware.ts diff --git a/app/dashboard/(overview)/page.tsx b/app/dashboard/(overview)/page.tsx index 4c57acd..541e91c 100644 --- a/app/dashboard/(overview)/page.tsx +++ b/app/dashboard/(overview)/page.tsx @@ -9,6 +9,11 @@ import { LatestInvoicesSkeleton, CardsSkeleton, } from "@/app/ui/skeletons"; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Dashboard | Acme Dashboard', +}; export default async function Page() { return ( diff --git a/app/dashboard/customers/page.tsx b/app/dashboard/customers/page.tsx index f07a730..2d1fb58 100644 --- a/app/dashboard/customers/page.tsx +++ b/app/dashboard/customers/page.tsx @@ -1,3 +1,9 @@ +import { Metadata } from "next" + +export const metadata: Metadata = { + title: 'Customers | Acme Dashboard', +}; + export default function Page() { return

Customers Page

} \ No newline at end of file diff --git a/app/dashboard/invoices/[id]/edit/page.tsx b/app/dashboard/invoices/[id]/edit/page.tsx index 842545e..a544fa3 100644 --- a/app/dashboard/invoices/[id]/edit/page.tsx +++ b/app/dashboard/invoices/[id]/edit/page.tsx @@ -3,6 +3,12 @@ import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data'; import { notFound } from 'next/navigation'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Edit Invoice', +}; + export default async function Page({ params }: { params: { id: string } }) { const id = params.id; const [invoice, customers] = await Promise.all([ diff --git a/app/dashboard/invoices/create/page.tsx b/app/dashboard/invoices/create/page.tsx index 0568f27..dd62b41 100644 --- a/app/dashboard/invoices/create/page.tsx +++ b/app/dashboard/invoices/create/page.tsx @@ -1,6 +1,11 @@ import Form from '@/app/ui/invoices/create-form'; import Breadcrumbs from '@/app/ui/invoices/breadcrumbs'; import { fetchCustomers } from '@/app/lib/data'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Create Invoice', +}; export default async function Page() { const customers = await fetchCustomers(); diff --git a/app/dashboard/invoices/page.tsx b/app/dashboard/invoices/page.tsx index 984404d..36a5073 100644 --- a/app/dashboard/invoices/page.tsx +++ b/app/dashboard/invoices/page.tsx @@ -6,6 +6,11 @@ import { lusitana } from '@/app/ui/fonts'; import { InvoicesTableSkeleton } from '@/app/ui/skeletons'; import { Suspense } from 'react'; import { fetchInvoicesPages } from '@/app/lib/data'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Invoices', +}; export default async function Page({ searchParams, diff --git a/public/favicon.ico b/app/favicon.ico similarity index 100% rename from public/favicon.ico rename to app/favicon.ico diff --git a/app/layout.tsx b/app/layout.tsx index bc3a4c8..1570232 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,15 @@ import '@/app/ui/global.css'; import { inter } from '@/app/ui/fonts'; +import { Metadata } from 'next'; + +export const metadata: Metadata = { + title: { + template: '%s | Acme Dashboard', + default: 'Acme Dashboard', + }, + description: 'The official Next.js Course Dashboard, built with App Router', + metadataBase: new URL('https://next-learn-dashboard.vercel.sh'), +}; export default function RootLayout({ children, diff --git a/app/lib/actions.ts b/app/lib/actions.ts index 09d84de..05daa7b 100644 --- a/app/lib/actions.ts +++ b/app/lib/actions.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import { sql } from '@vercel/postgres'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; +import { signIn } from '@/auth'; +import { AuthError } from 'next-auth'; const FormSchema = z.object({ id: z.string(), @@ -113,4 +115,23 @@ export async function deleteInvoice(id: string) { } catch (error) { return {message: 'Database Error: Failed to Delete Invoice.'}; } +} + +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/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..a4337b6 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,22 @@ +import AcmeLogo from "@/app/ui/acme-logo"; +import LoginForm from "@/app/ui/login-form"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: 'Login | Acme Dashboard', +}; + +export default function LoginPage() { + return ( +
+
+
+
+ +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/public/opengraph-image.png b/app/opengraph-image.png similarity index 100% rename from public/opengraph-image.png rename to app/opengraph-image.png diff --git a/app/ui/dashboard/sidenav.tsx b/app/ui/dashboard/sidenav.tsx index 3d55b46..a04d0cc 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,11 @@ export default function SideNav() {
-
+ { + 'use server'; + await signOut(); + }}>
-
- {/* Add form errors here */} +
+ {errorMessage && ( + <> + +

{errorMessage}

+ + )}
@@ -65,8 +79,10 @@ 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..939b1bd --- /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; + } else if (isLoggedIn) { + return Response.redirect(new URL('/dashboard', nextUrl)); + } + return true; + }, + }, + providers: [], +} satisfies NextAuthConfig; \ No newline at end of file diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..76cf8e4 --- /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] as User; + } 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 passwordsMatch = await bcrypt.compare(password, user.password); + + if (passwordsMatch) 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 9c878ab..7c74b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "bcrypt": "^5.1.1", "clsx": "^2.0.0", "next": "^14.0.2", + "next-auth": "^5.0.0-beta.16", "postcss": "8.4.31", "react": "18.2.0", "react-dom": "18.2.0", @@ -70,6 +71,36 @@ "node": ">=6.0.0" } }, + "node_modules/@auth/core": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.28.1.tgz", + "integrity": "sha512-gvp74mypYZADpTlfGRp6HE0G3pIHWvtJpy+KZ+8FvY0cmlIpHog+jdMOdd29dQtLtN25kF2YbfHsesCFuGUQbg==", + "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": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "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", @@ -1036,6 +1067,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/@pkgr/utils": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", @@ -1090,6 +1129,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", @@ -2297,6 +2341,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", @@ -4656,6 +4708,14 @@ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/jose": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.4.tgz", + "integrity": "sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==", + "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", @@ -5057,6 +5117,32 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.16", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.16.tgz", + "integrity": "sha512-dX2snB+ezN23tFzSes3n3uosT9iBf0eILPYWH/R2fd9n3ZzdMQlRzq7JIOPeS1aLc84IuRlyuyXyx9XmmZB6og==", + "dependencies": { + "@auth/core": "0.28.1" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14", + "nodemailer": "^6.6.5", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "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", @@ -5185,6 +5271,14 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth4webapi": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.4.tgz", + "integrity": "sha512-DSoj8QoChzOCQlJkRmYxAJCIpnXFW32R0Uq7avyghIeB6iJq0XAblOD7pcq3mx4WEBDwMuKr0Y1qveCBleG2Xw==", + "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", @@ -5705,6 +5799,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", @@ -5819,6 +5933,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 0fffe8c..ef2efdd 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "bcrypt": "^5.1.1", "clsx": "^2.0.0", "next": "^14.0.2", + "next-auth": "^5.0.0-beta.16", "postcss": "8.4.31", "react": "18.2.0", "react-dom": "18.2.0",