diff --git a/.vscode/settings.json b/.vscode/settings.json index 91af0e49..4d1e42ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "qaub", "QRSTUVWXYZ", "Reala", + "CredentialsSignin", "skippable", "sonner", "thephotoblog", diff --git a/README.md b/README.md index 6a522602..a0f985d3 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` 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) @@ -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). 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/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/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..c7f840b4 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -3,60 +3,55 @@ 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'; +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 [isSigningIn, setIsSigningIn] = useState(false); + const [response, action] = useFormState(signInAction, undefined); const emailRef = useRef(null); useLayoutEffect(() => { emailRef.current?.focus(); }, []); + const isFormValid = + email.length > 0 && + password.length > 0; + return ( -
{ - e.preventDefault(); - setIsSigningIn(true); - signIn( - 'credentials', - { - email, - password, - callbackUrl: PATH_ADMIN_PHOTOS, - }, - ) - .catch(() => setIsSigningIn(false)); - }} - > -
- - + +
+ {response === CREDENTIALS_SIGN_IN_ERROR && + + Invalid email/password + } +
+ + +
+ + Sign in +
- - Sign in - ); diff --git a/src/auth/action.ts b/src/auth/action.ts new file mode 100644 index 00000000..734da6e9 --- /dev/null +++ b/src/auth/action.ts @@ -0,0 +1,21 @@ +'use server'; + +import { CREDENTIALS_SIGN_IN_ERROR, signIn, signOut } from '@/auth'; + +export const signInAction = async ( + _prevState: string | undefined, + formData: FormData, +) => { + try { + await signIn('credentials', Object.fromEntries(formData)); + } catch (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 f548eb5c..db3cc98d 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,25 +1,17 @@ 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 CREDENTIALS_SIGN_IN_ERROR = 'CredentialsSignin'; export const { handlers: { GET, POST }, + signIn, + signOut, 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 && 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' 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/ImageInput.tsx b/src/components/ImageInput.tsx index 3c9d8ca9..3241786b 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'; @@ -49,10 +49,10 @@ export default function ImageInput({ > {loading - ? - : + : } Upload Photo diff --git a/src/components/SubmitButtonWithStatus.tsx b/src/components/SubmitButtonWithStatus.tsx index 5ec49458..bb9d661e 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 + styleAsLink?: boolean } export default function SubmitButtonWithStatus(props: Props) { const { icon, + styleAsLink, children, disabled, className, @@ -29,14 +31,17 @@ export default function SubmitButtonWithStatus(props: Props) { className={cc( className, 'inline-flex items-center gap-2', + styleAsLink && 'link', )} {...buttonProps} > {(icon || pending) && {pending ? diff --git a/src/site/FooterAuth.tsx b/src/site/FooterAuth.tsx index cab90f4e..5a6fc589 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..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,6 +98,11 @@ @apply text-medium } + button.link { + @apply + p-0 min-h-0 + border-none active:bg-transparent shadow-none + } /* Toasts */ .toaster [data-sonner-toast] { @apply