From 5acb257c83403fd255ad33c4202847260743d42a Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 30 Oct 2023 19:59:27 -0500 Subject: [PATCH 1/9] Refactor core auth primitives --- package.json | 2 +- pnpm-lock.yaml | 10 ++++----- src/app/api/{ => auth}/[...nextauth]/route.ts | 0 src/auth/SignInForm.tsx | 22 +++---------------- src/auth/action.ts | 12 ++++++++++ src/auth/index.ts | 16 ++------------ 6 files changed, 23 insertions(+), 39 deletions(-) rename src/app/api/{ => auth}/[...nextauth]/route.ts (100%) create mode 100644 src/auth/action.ts diff --git a/package.json b/package.json index 1c8317dc..8cf9717c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "jest-environment-jsdom": "^29.7.0", "nanoid": "^5.0.2", "next": "^14.0.1", - "next-auth": "0.0.0-manual.c885ac1d", + "next-auth": "5.0.0-beta.3", "next-themes": "^0.2.1", "postcss": "8.4.31", "react": "18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04bea76..2826b6e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,8 +75,8 @@ dependencies: specifier: ^14.0.1 version: 14.0.1(@babel/core@7.23.0)(react-dom@18.2.0)(react@18.2.0) next-auth: - specifier: 0.0.0-manual.c885ac1d - version: 0.0.0-manual.c885ac1d(next@14.0.1)(react@18.2.0) + specifier: 5.0.0-beta.3 + version: 5.0.0-beta.3(next@14.0.1)(react@18.2.0) next-themes: specifier: ^0.2.1 version: 0.2.1(next@14.0.1)(react-dom@18.2.0)(react@18.2.0) @@ -4053,10 +4053,10 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: false - /next-auth@0.0.0-manual.c885ac1d(next@14.0.1)(react@18.2.0): - resolution: {integrity: sha512-kL5Ead+uIQNjfSWjo/MVxzte+jJSD+N/XIGkYmqyjQmNxE5wDvJ6zuwo+h+QPBnTVf+jmOsOlzr65tPN7OY5fA==} + /next-auth@5.0.0-beta.3(next@14.0.1)(react@18.2.0): + resolution: {integrity: sha512-WOKhATBFGeONV+29HzFmspNmL7NXxrsCWLfaDKmAd/4DD1nqXE0BzNFH8t3SJBx7PUDMnB6F7xB76LM/AaV1MQ==} peerDependencies: - next: ^13.5.3 + next: ^14 nodemailer: ^6.6.5 react: ^18.2.0 peerDependenciesMeta: diff --git a/src/app/api/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts similarity index 100% rename from src/app/api/[...nextauth]/route.ts rename to src/app/api/auth/[...nextauth]/route.ts diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index a8e717bd..8aafd5fe 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -3,14 +3,12 @@ import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import InfoBlock from '@/components/InfoBlock'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; -import { PATH_ADMIN_PHOTOS } from '@/site/paths'; -import { signIn } from 'next-auth/react'; import { useLayoutEffect, useRef, useState } from 'react'; +import { signInAction } from './action'; export default function SignInForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [isSigningIn, setIsSigningIn] = useState(false); const emailRef = useRef(null); useLayoutEffect(() => { @@ -21,19 +19,7 @@ export default function SignInForm() {
{ - e.preventDefault(); - setIsSigningIn(true); - signIn( - 'credentials', - { - email, - password, - callbackUrl: PATH_ADMIN_PHOTOS, - }, - ) - .catch(() => setIsSigningIn(false)); - }} + action={signInAction} >
- + Sign in diff --git a/src/auth/action.ts b/src/auth/action.ts new file mode 100644 index 00000000..b0558125 --- /dev/null +++ b/src/auth/action.ts @@ -0,0 +1,12 @@ +'use server'; + +import { signIn } from '@/auth'; + +export const signInAction = async (formData: FormData) => { + try { + signIn('credentials', Object.fromEntries(formData)); + } catch (error) { + console.log('Cannot sign in user', error); + throw(error); + } +}; diff --git a/src/auth/index.ts b/src/auth/index.ts index f548eb5c..44c685e8 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,25 +1,13 @@ import { isPathProtected } from '@/site/paths'; -import NextAuth, { User, type DefaultSession } from 'next-auth'; +import NextAuth, { User } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; - -declare module 'next-auth' { - interface Session { - user: { - id: string - } & DefaultSession['user'] - } -} - export const { handlers: { GET, POST }, + signIn, auth, } = NextAuth({ providers: [ Credentials({ - credentials: { - email: { label: 'Email', type: 'text' }, - password: { label: 'Password', type: 'password' }, - }, async authorize({ email, password }) { if ( process.env.ADMIN_EMAIL && process.env.ADMIN_EMAIL === email && From b12c4d3057e91b189ab4eb8da191a32901a01737 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 30 Oct 2023 22:20:54 -0500 Subject: [PATCH 2/9] Refine next-auth 5.0 behavior --- .vscode/settings.json | 1 + src/auth/SignInForm.tsx | 53 +++++++++++++---------- src/auth/action.ts | 19 +++++--- src/auth/index.ts | 4 ++ src/components/ErrorNote.tsx | 25 +++++++++++ src/components/SubmitButtonWithStatus.tsx | 3 ++ src/site/FooterAuth.tsx | 50 +++++++++++---------- src/site/globals.css | 5 +++ 8 files changed, 109 insertions(+), 51 deletions(-) create mode 100644 src/components/ErrorNote.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 91af0e49..fc4ff045 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "qaub", "QRSTUVWXYZ", "Reala", + "Signin", "skippable", "sonner", "thephotoblog", diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 8aafd5fe..0cca6c9f 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -5,10 +5,14 @@ import InfoBlock from '@/components/InfoBlock'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import { useLayoutEffect, useRef, useState } from 'react'; import { signInAction } from './action'; +import { useFormState } from 'react-dom'; +import ErrorNote from '@/components/ErrorNote'; +import { CREDENTIALS_SIGN_IN_ERROR } from '.'; export default function SignInForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [response, action] = useFormState(signInAction, undefined); const emailRef = useRef(null); useLayoutEffect(() => { @@ -17,30 +21,33 @@ export default function SignInForm() { return ( -
-
- - + +
+ {response === CREDENTIALS_SIGN_IN_ERROR && + + Invalid email/password + } +
+ + +
+ + Sign in +
- - Sign in - ); diff --git a/src/auth/action.ts b/src/auth/action.ts index b0558125..734da6e9 100644 --- a/src/auth/action.ts +++ b/src/auth/action.ts @@ -1,12 +1,21 @@ 'use server'; -import { signIn } from '@/auth'; +import { CREDENTIALS_SIGN_IN_ERROR, signIn, signOut } from '@/auth'; -export const signInAction = async (formData: FormData) => { +export const signInAction = async ( + _prevState: string | undefined, + formData: FormData, +) => { try { - signIn('credentials', Object.fromEntries(formData)); + await signIn('credentials', Object.fromEntries(formData)); } catch (error) { - console.log('Cannot sign in user', error); - throw(error); + if ((error as Error).message.includes(CREDENTIALS_SIGN_IN_ERROR)) { + return CREDENTIALS_SIGN_IN_ERROR; + } + throw error; } }; + +export const signOutAction = async () => { + await signOut(); +}; diff --git a/src/auth/index.ts b/src/auth/index.ts index 44c685e8..db3cc98d 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,9 +1,13 @@ import { isPathProtected } from '@/site/paths'; import NextAuth, { User } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; + +export const CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin'; + export const { handlers: { GET, POST }, signIn, + signOut, auth, } = NextAuth({ providers: [ diff --git a/src/components/ErrorNote.tsx b/src/components/ErrorNote.tsx new file mode 100644 index 00000000..aaca7190 --- /dev/null +++ b/src/components/ErrorNote.tsx @@ -0,0 +1,25 @@ +import { cc } from '@/utility/css'; +import { BiErrorAlt } from 'react-icons/bi'; + +export default function ErrorNote({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 5ec49458..52e767c5 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -8,11 +8,13 @@ import { cc } from '@/utility/css'; interface Props extends HTMLProps { icon?: JSX.Element + naked?: boolean } export default function SubmitButtonWithStatus(props: Props) { const { icon, + naked, children, disabled, className, @@ -29,6 +31,7 @@ export default function SubmitButtonWithStatus(props: Props) { className={cc( className, 'inline-flex items-center gap-2', + naked && 'naked', )} {...buttonProps} > diff --git a/src/site/FooterAuth.tsx b/src/site/FooterAuth.tsx index cab90f4e..3bdef328 100644 --- a/src/site/FooterAuth.tsx +++ b/src/site/FooterAuth.tsx @@ -2,22 +2,23 @@ import { cc } from '@/utility/css'; import Link from 'next/link'; -import { useSession, signOut } from 'next-auth/react'; +import { useSession } from 'next-auth/react'; import ThemeSwitcher from '@/site/ThemeSwitcher'; import SiteGrid from '../components/SiteGrid'; import { usePathname } from 'next/navigation'; import { isPathSignIn } from '@/site/paths'; +import { signOutAction } from '@/auth/action'; +import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; const LINK_STYLE = cc( 'cursor-pointer', - 'hover:text-gray-600', + 'hover:text-gray-300', + 'hover:dark:text-gray-600', ); export default function FooterAuth() { const { data: session, status } = useSession(); - const hasState = status !== 'loading'; - const path = usePathname(); return ( @@ -27,27 +28,30 @@ export default function FooterAuth() { 'my-8', 'text-dim', )}> -
- {hasState - ? <> - {session?.user === undefined && - <>Loading ...} - {session?.user.email && <> -
{session.user.email}
-
signOut()} +
+ {status === 'loading' + ? <>Loading ... + : <> + {session?.user?.email &&
+ {session.user.email} +
} + {status === 'authenticated' && +
+ + Sign Out + +
} + {status === 'unauthenticated' && + - Sign Out -
- } - - : - Sign In - } + Sign In + } + }
{!isPathSignIn(path) && }
} diff --git a/src/site/globals.css b/src/site/globals.css index 242dca91..9dc9a043 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -97,6 +97,11 @@ @apply text-medium } + button.naked { + @apply + p-0 min-h-0 + border-none active:bg-transparent shadow-none + } /* Toasts */ .toaster [data-sonner-toast] { @apply From f11bed9821b19b3f23a40206df720849b548b35e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 30 Oct 2023 22:26:33 -0500 Subject: [PATCH 3/9] Chance AuthSession type reference --- src/cache/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cache/index.ts b/src/cache/index.ts index 282baf77..ccc85309 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -15,7 +15,7 @@ import { } from '@/services/postgres'; import { parseCachedPhotosDates, parseCachedPhotoDates } from '@/photo'; import { getBlobPhotoUrls, getBlobUploadUrls } from '@/services/blob'; -import { AuthSession } from 'next-auth'; +import type { Session } from 'next-auth'; import { Camera, createCameraKey } from '@/camera'; import { PATHS_ADMIN, PATHS_TO_CACHE } from '@/site/paths'; @@ -226,7 +226,7 @@ export const getBlobPhotoUrlsCached: typeof getBlobPhotoUrls = (...args) => } )(); -export const getImageCacheHeadersForAuth = (session: AuthSession | null) => { +export const getImageCacheHeadersForAuth = (session: Session | null) => { return { 'Cache-Control': !session?.user ? 's-maxage=3600, stale-while-revalidate=59' From b3f7d2794c9290df497706a3484ac431d1793495 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 30 Oct 2023 23:07:46 -0500 Subject: [PATCH 4/9] Update local dev instructions for next-auth v5 --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6a522602..d4208b27 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ Installation ### 4. Develop locally 1. Clone code -2. Install dependencies `pnpm i` -3. Run `vc dev` to utilize Vercel-stored environment variables +2. Run `pnpm i` to install dependencies +3. Set environment variable `AUTH_URL` to `http://localhost:3000/api/url` (_this is a temporary limitation of `next-auth` v5.0_) +4. Run `vc dev` to start dev server, and utilize Vercel-stored environment variables ### 5. Add Analytics (optional) @@ -68,8 +69,8 @@ Installation FAQ - -Q: My images/content have fallen out of sync with my database and/or production site no longer matches local development. What do I do?
-A: Navigate to `/admin/configuration` and click the "Clear Cache" button. +Q: My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?
+A: Navigate to `/admin/configuration` and click "Clear Cache" button. Q: I'm seeing server-side runtime errors when loading a page after updating my fork. What do I do?
-A: Navigate to `/admin/configuration` and click the "Clear Cache" button. If this doesn't help, [open an issue](https://github.com/sambecker/exif-photo-blog/issues/new). +A: Navigate to `/admin/configuration` and click "Clear Cache" button. If this doesn't help, [open an issue](https://github.com/sambecker/exif-photo-blog/issues/new). From e57556977e3ae963bd4f7899ef1428a5d74f259e Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 31 Oct 2023 11:44:22 -0500 Subject: [PATCH 5/9] Tweak button styles --- src/admin/DeleteButton.tsx | 3 ++- src/auth/SignInForm.tsx | 6 +++++- src/components/ImageInput.tsx | 4 ++-- src/components/SubmitButtonWithStatus.tsx | 8 +++++--- src/site/FooterAuth.tsx | 2 +- src/site/globals.css | 7 ++++--- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/admin/DeleteButton.tsx b/src/admin/DeleteButton.tsx index 4709a58e..99307aea 100644 --- a/src/admin/DeleteButton.tsx +++ b/src/admin/DeleteButton.tsx @@ -1,9 +1,10 @@ import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; +import { FaTimes } from 'react-icons/fa'; export default function DeleteButton () { return ×} + icon={} > Delete ; diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 0cca6c9f..c7f840b4 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -19,6 +19,10 @@ export default function SignInForm() { emailRef.current?.focus(); }, []); + const isFormValid = + email.length > 0 && + password.length > 0; + return (
@@ -44,7 +48,7 @@ export default function SignInForm() { onChange={setPassword} />
- + Sign in diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index 3c9d8ca9..f3b2c70f 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -4,9 +4,9 @@ import { blobToImage } from '@/utility/blob'; import { useRef, useState } from 'react'; import { CopyExif } from '@/lib/CopyExif'; import { cc } from '@/utility/css'; -import { AiOutlineCloudUpload } from 'react-icons/ai'; import Spinner from './Spinner'; import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; +import { FiUploadCloud } from 'react-icons/fi'; const INPUT_ID = 'file'; @@ -50,7 +50,7 @@ export default function ImageInput({ {loading ? - : } diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 52e767c5..bb9d661e 100644 --- a/src/components/SubmitButtonWithStatus.tsx +++ b/src/components/SubmitButtonWithStatus.tsx @@ -8,13 +8,13 @@ import { cc } from '@/utility/css'; interface Props extends HTMLProps { icon?: JSX.Element - naked?: boolean + styleAsLink?: boolean } export default function SubmitButtonWithStatus(props: Props) { const { icon, - naked, + styleAsLink, children, disabled, className, @@ -31,15 +31,17 @@ export default function SubmitButtonWithStatus(props: Props) { className={cc( className, 'inline-flex items-center gap-2', - naked && 'naked', + styleAsLink && 'link', )} {...buttonProps} > {(icon || pending) && {pending ? diff --git a/src/site/FooterAuth.tsx b/src/site/FooterAuth.tsx index 3bdef328..5a6fc589 100644 --- a/src/site/FooterAuth.tsx +++ b/src/site/FooterAuth.tsx @@ -39,7 +39,7 @@ export default function FooterAuth() { Sign Out diff --git a/src/site/globals.css b/src/site/globals.css index 9dc9a043..7a080dc2 100644 --- a/src/site/globals.css +++ b/src/site/globals.css @@ -72,10 +72,11 @@ px-4 text-base shadow-sm - disabled:bg-gray-100 dark:disabled:bg-gray-900 disabled:cursor-not-allowed active:bg-gray-100 dark:active:bg-gray-900 hover:border-gray-300 dark:hover:border-gray-600 - hover:disabled:border-gray-200 + disabled:cursor-not-allowed + disabled:bg-gray-100 dark:disabled:bg-gray-900 + disabled:border-gray-200 disabled:dark:border-gray-700 } button.subtle, .button.subtle { @apply @@ -97,7 +98,7 @@ @apply text-medium } - button.naked { + button.link { @apply p-0 min-h-0 border-none active:bg-transparent shadow-none From 06a6c7a717222c0d00d415102030a36e53e62b0c Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 31 Oct 2023 11:48:30 -0500 Subject: [PATCH 6/9] Tweak upload icon --- src/components/ImageInput.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index f3b2c70f..b0fb7a66 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -6,7 +6,7 @@ import { CopyExif } from '@/lib/CopyExif'; import { cc } from '@/utility/css'; import Spinner from './Spinner'; import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; -import { FiUploadCloud } from 'react-icons/fi'; +import { TbCloudUpload } from 'react-icons/tb'; const INPUT_ID = 'file'; @@ -49,10 +49,10 @@ export default function ImageInput({ > {loading - ? - : + : } Upload Photo From f382968aac1caa378180ad53d078f35012899144 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 31 Oct 2023 11:53:28 -0500 Subject: [PATCH 7/9] Tweak cloud icon again --- src/components/ImageInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ImageInput.tsx b/src/components/ImageInput.tsx index b0fb7a66..3241786b 100644 --- a/src/components/ImageInput.tsx +++ b/src/components/ImageInput.tsx @@ -6,7 +6,7 @@ import { CopyExif } from '@/lib/CopyExif'; import { cc } from '@/utility/css'; import Spinner from './Spinner'; import { ACCEPTED_PHOTO_FILE_TYPES } from '@/photo'; -import { TbCloudUpload } from 'react-icons/tb'; +import { FiUploadCloud } from 'react-icons/fi'; const INPUT_ID = 'file'; @@ -50,7 +50,7 @@ export default function ImageInput({ {loading ? - : } From 44139d8c863c2a51c0fa2c75f36cc8aa267d7bcd Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 31 Oct 2023 11:58:07 -0500 Subject: [PATCH 8/9] Tweak next-auth README instructions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d4208b27..a0f985d3 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Installation 1. Clone code 2. Run `pnpm i` to install dependencies -3. Set environment variable `AUTH_URL` to `http://localhost:3000/api/url` (_this is a temporary limitation of `next-auth` v5.0_) +3. Set environment variable `AUTH_URL` locally (not in production) to `http://localhost:3000/api/url` (_this is a temporary limitation of `next-auth` v5.0_) 4. Run `vc dev` to start dev server, and utilize Vercel-stored environment variables ### 5. Add Analytics (optional) From 267c55fb807f7e09fae6b7b9683c2f559ef64c50 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 31 Oct 2023 12:13:01 -0500 Subject: [PATCH 9/9] Tweak spelling dictionary --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fc4ff045..4d1e42ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,7 +19,7 @@ "qaub", "QRSTUVWXYZ", "Reala", - "Signin", + "CredentialsSignin", "skippable", "sonner", "thephotoblog",